- Go 89.4%
- Shell 5.7%
- Makefile 4.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Cobra/Viper boilerplate plus a UCAN-mode chat: per-channel sealed-box encryption, signed delegations for /chat/post and /chat/read, member sub-delegation with inline proof chains, revocation, channel-key rotation, an auto-populated nick→DID registry, and per-nick input history (ctrl+r reverse search). Walkthrough in docs/demo.md. |
||
| cmd | ||
| deploy/mosquitto | ||
| docs | ||
| internal | ||
| scripts | ||
| .gitignore | ||
| config.example.yaml | ||
| go.mod | ||
| go.sum | ||
| ideas.md | ||
| main.go | ||
| Makefile | ||
| README.md | ||
ucan
A boilerplate Go CLI built with cobra + viper, plus a working MQTT-backed TUI chat (Bubble Tea) as a sample subcommand. Every flag is also reachable via config file and environment variables.
Build
go build -o ucan .
./ucan --help
./ucan version
Configuration
Order of precedence (highest first):
- CLI flag (
--broker tcp://...) - Environment variable (
UCAN_BROKER=tcp://...) - Config file (YAML)
- Built-in default
Config file search order:
--config <path>./ucan.yaml$XDG_CONFIG_HOME/ucan/config.yaml(or$HOME/.config/ucan/config.yaml)$HOME/.ucan.yaml
A complete sample lives in config.example.yaml.
Chat demo
Requires a local MQTT broker (e.g. mosquitto):
brew services start mosquitto # macOS
# or: mosquitto -p 1883 -v
Run two or more instances in different terminals:
# terminal 1
./ucan chat --nick alice --join general,ops
# terminal 2
./ucan chat --nick bob --join general
# terminal 3 — config-file driven
cp config.example.yaml ucan.yaml # tweak nick: carol
./ucan chat # picks up ./ucan.yaml automatically
# terminal 4 — env-driven
UCAN_NICK=dave UCAN_TOPIC_PREFIX=ucan ./ucan chat --join ops
TUI keys
| key | action |
|---|---|
enter |
send message to active channel (or run slash command) |
tab / shift-tab |
cycle active channel in the sidebar |
pgup / pgdn |
scroll the message log |
ctrl+c |
quit |
Slash commands
| command | effect |
|---|---|
/join <chan> |
subscribe to <topic-prefix>/<chan> and switch to it |
/leave [<chan>] |
unsubscribe (default: active channel) |
/list |
list joined channels |
/nick <name> |
change display name |
/help |
show command reference |
/quit |
disconnect and exit |
Topic mapping
A channel <c> maps to MQTT topic <topic-prefix>/<c>. With the default
prefix ucan, joining general subscribes to ucan/general. You can
sniff traffic with:
mosquitto_sub -h 127.0.0.1 -t 'ucan/#' -v
The wire format is JSON:
{ "from": "<client-id>", "nick": "alice", "body": "hello", "ts": 1714128000, "kind": "msg" }
TLS / mutual TLS
ucan ships a complete TLS workflow driven by make. Mosquitto runs in an
Apple container on :8883 (mTLS) and
:1884 (plain). Each user gets a single self-contained YAML config that
embeds the CA, their cert, and their private key — ucan --config <file> is
all that's needed to connect, and the user's nick/email are read from the
client cert (CN + rfc822Name SAN), not the file.
make tls-init # CA + server cert
make tls-user NICK=alice EMAIL=alice@example.com # ./users/alice.yaml
make tls-user NICK=bob EMAIL=bob@example.com # ./users/bob.yaml
make mosquitto-up # spin up broker
make run-tls USER=alice # connect with mTLS
The broker rejects clients that don't present a cert signed by our CA
(require_certificate true, see deploy/mosquitto/mosquitto-tls.conf).
Sniff traffic with:
mosquitto_sub -h 127.0.0.1 -p 8883 \
--cafile certs/ca.crt --cert certs/users/alice.crt --key certs/users/alice.key \
-t 'ucan/#' -v
UCAN showcase
When a user YAML carries a UCAN block (make tls-user writes one
automatically), ucan chat runs in UCAN mode — channels are end-to-end
encrypted under a per-channel key (CK), and posting is authorized by signed
UCAN delegations rather than by raw broker ACLs.
make demo-setup # CA + alice/bob/carol/mallory + broker + binary
make demo # 4-pane tmux session running each client
The walkthrough that exercises every UCAN feature
(invite → accept → post → inspect → attenuated/read-only → TTL expiry →
revocation → CK rotation) is in docs/demo.md.
UCAN slash commands
| command | effect |
|---|---|
/whoami |
show your DID + nick + joined UCAN channels |
/channel-create <name> |
mint a fresh channel (you become admin) |
/invite <chan> <nick|did> [--read-only] [--ttl 1h] [--file path] |
issue a delegation. Admin → root delegation off the channel key. Member → sub-delegation rooted on your own delegation, attenuated by the parent's command and TTL. Recipient can be a known nick (see /who) or a literal did:key:. Delivered to the recipient's inbox topic or written to <path>. |
/accept [<file>] |
open the newest pending invite (or a file), decrypt CK, join |
/inbox |
list pending invitations |
/inspect |
show the verified UCAN chain of the most recent message |
/revoke <chan> <cid | did> |
admin-only: broadcast a revocation envelope |
/rotate-key [<chan>] |
admin-only: roll the CK and re-seal it for current members |
/who |
list known nicks → DIDs from the local registry |
/register <nick> <did:key:...> |
manually map a nick to a DID |
/forget <nick> |
drop a nick from the registry |
The nick → DID directory auto-populates from every verified envelope and
persists to ucan-nick-registry.json in CWD. Once /who shows someone, you
can address them by nick anywhere a <did> is accepted.
Sub-delegation (members can re-invite)
Any member with /chat/post can run /invite themselves to bring a guest in,
without going through the admin. The invite mints a fresh delegation signed
by the member, citing their own delegation as proof — receivers walk the
chain guest → member → channel root and verify each hop. Attenuation is
strict: a /chat/read member can only sub-invite read access, never upgrade
to post; the child's effective TTL can never exceed the parent's; revoking a
parent kills every descendant on its next post. The full chain ships inside
each envelope, so peers who never saw the intermediate delegations can still
verify a guest's messages.
Input history
Every line you send is appended to ucan-chat-history-<nick>.json in CWD.
up / down walks the history; ctrl+r is reverse-incremental search like
in bash. History is per-nick and survives restarts.
Topic mapping (UCAN mode)
| topic | content |
|---|---|
<prefix>/inbox/<msi> |
sealed invitations addressed to a recipient DID |
<prefix>/c/<channel-msi> |
encrypted chat envelopes (sealed-box over secretbox) |
<prefix>/ctrl/<channel-msi>/revocations |
signed revocation envelopes |
<prefix>/ctrl/<channel-msi>/keys |
per-recipient sealed CK rotation bundles |
<msi> is the multibase-encoded suffix of a did:key (e.g. z6Mk…).
Tests
make test
The MQTT tests require a broker on 127.0.0.1:1883 (plain tests) and
127.0.0.1:8883 with valid certs (TLS tests). They self-skip if the broker
or certs are missing.
Layout
ucan/
├── main.go
├── cmd/ # cobra commands
│ ├── root.go # persistent flags + viper wiring
│ ├── chat.go # `ucan chat`
│ └── version.go
├── internal/
│ ├── config/ # typed Config consumed by subcommands
│ ├── mqtt/ # paho wrapper: Connect/Join/Leave/Publish/Incoming
│ ├── ucanid/ # UCAN identity (Ed25519 + derived X25519, did:key)
│ ├── ucanchat/ # delegations, invitations, envelopes, revocation, key rotation
│ └── tui/ # Bubble Tea model/update/view + slash commands
└── config.example.yaml