Skip to content

Commit ebe72f5

Browse files
frahlgclaude
andauthored
feat: B2 wallet device registry (stateless, encrypted, blind relay) (#48)
* feat(identity): B2.0 registry key + ChaCha20-Poly1305 record seal/open (Go+JS+vector) K_reg = HKDF-SHA256(wallet_secret, salt 'miranda/registry/v1'); records are ChaCha20-Poly1305 sealed with machine_id as AAD (encryption AND authenticity: a forged blob fails to open). Byte-identical Go<->JS, gated by testdata/registry-vector.json (cross-checked vs Python cryptography). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(signal): B2.1 stateless blind device registry (blob on live reg + GET /registry) Agents publish an opaque encrypted device blob as the first message on their live /agent/signal registration; the relay holds it in-memory on the agentConn (no persistence) and serves GET /registry?wallet=W -> [{machine_id, blob}] for the live agents under W. It never decrypts, verifies, or persists — blind and stateless; a relay restart loses it and agents re-publish on reconnect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(agent): B2.2 mir up auto-serves own wallet + publishes encrypted registry record A wallet-rooted mir up auto-pins its own wallet (your own devices need no pairing) and publishes a ChaCha20-Poly1305-sealed {name,host_pub,signal_url,ts} record on its live /agent/signal registration, re-published on each reconnect. Legacy (wallet-less) mir up is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(client): B2.3 registry discovery — list/attach auto-find your machines + notify mir list and mir attach <name> fetch the relay's encrypted registry, decrypt with the wallet (forgeries dropped), and merge with local machines.json — no add-machine needed for your own devices. A newly-seen machine_id prints a one-line "new device joined" notice (seen-set in seen.json). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(web): B2.4 auto-list your machines from the encrypted registry + notify Sign-in keeps the prf secret in memory so the session can derive K_reg; the machine view renders the local list immediately, then enriches it from GET /registry?wallet= (same-origin, best-effort): decrypt each blob with the wallet (forgeries dropped), merge (local wins), and show a one-line "new device joined" notice. Discovery only — attach path unchanged. Verified in-browser: sign-in → machine list renders, no errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test+docs(registry): B2.5 e2e contract test + README/SECURITY A real-relay e2e: an agent seals a record + base64s it onto its live registration, the relay serves it verbatim (blind), and the fetcher base64-decodes + OpenRecords it (machine_id AAD) to recover the record — a forgery is dropped, a fresh relay is empty (stateless). Catches cross- component contract drift the per-slice fixtures can't. README documents auto-discovery; SECURITY documents the encrypted/blind/stateless registry. Deploy of the new mir-signal (/registry endpoint) is Fredrik's hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 10b1fb5 commit ebe72f5

24 files changed

Lines changed: 1670 additions & 18 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ mir attach laptop macmini linux
7878
Everything defaults to the hosted relay + STUN, so no flags are needed. Point at your
7979
own infrastructure with `--signal` / `MIR_SIGNAL` and `--stun` / `MIR_STUN`.
8080

81+
**Your machines appear automatically.** Once they share your wallet (passkey-sync, or
82+
`mir wallet import-phrase` on a new machine), `mir up` publishes an **encrypted** record to
83+
the relay and your machines show up by name in `mir list` and the browser — no
84+
`mir add-machine`, no pairing between your own devices. The relay only ever holds opaque
85+
blobs it can't read; only your wallet decrypts them, and a forged record simply fails to
86+
open. A new machine prints a one-line "new device joined" notice. It's online-discovery:
87+
a powered-off machine reappears when it comes back; to retire one, turn it off (or, if a
88+
device is compromised, rotate with `mir keygen --wallet`).
89+
8190
**LAN-direct (no relay on the same network).** When the client and the machine are on
8291
the same LAN, `mir attach` finds it over mDNS and connects straight over QUIC — no relay
8392
round-trip. It's automatic and falls back to the relay within ~0.6 s if there's no local

SECURITY.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,18 @@ the network is hostile — provided the trust roots below are intact.
107107
(b) the mDNS advertisement reveals that a Miranda node with a given `machine_id`
108108
exists on the LAN. Disable both with `mir up --no-lan`; skip LAN discovery on a
109109
client with `mir attach --relay-only`.
110+
- **Device registry (auto-discovery).** `mir up` publishes a device record so your other
111+
devices find this machine by name with no manual pairing. The record (`name`, `host_pub`,
112+
`signal_url`) is **encrypted** with a key derived from your wallet (ChaCha20-Poly1305,
113+
HKDF of the wallet secret) before it leaves the machine, and the relay holds it **only
114+
in-memory**, tied to the live registration — **no persistence, no database**. The relay
115+
sees an opaque blob and which `machine_id`s are live under a wallet (the same linkability
116+
it already has at attach); it **cannot read the record, and cannot forge one** — a record
117+
sealed by anyone without your wallet fails to decrypt and is dropped by your devices, so
118+
the AEAD is the authenticity check (no relay verification needed). The blob is bound to
119+
its `machine_id` (AEAD associated data), so the relay can't even shuffle records between
120+
slots. Discovery is online-only; "revocation" is powering a device off (it stops
121+
registering) or, for a leaked phrase, rotating the wallet.
110122
- **Compromised endpoints / Keychain.** Out of scope — the same trust you already
111123
place in your own devices.
112124

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// go/internal/agent/registry_publish_test.go
2+
package agent
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/base64"
8+
"encoding/json"
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
"time"
13+
14+
"github.com/coder/websocket"
15+
"github.com/srcful/terminal-relay/go/internal/identity"
16+
"github.com/srcful/terminal-relay/go/internal/signal"
17+
)
18+
19+
// TestRegistryBlobOpens proves the agent seals a device record under K_reg that a
20+
// wallet-holder can open: registryBlob() returns base64(nonce||ct) which, decoded
21+
// and run through OpenRecord with the wallet's K_reg and the machine_id AAD, yields
22+
// the {name,host_pub,...} JSON. A wrong machine_id (AAD) must fail to open.
23+
func TestRegistryBlobOpens(t *testing.T) {
24+
secret := bytes.Repeat([]byte{0x42}, 32)
25+
cfg := &Config{
26+
MachineID: "machine-xyz",
27+
MachineName: "fredde-laptop",
28+
HostPubHex: "deadbeefcafef00d",
29+
SignalURL: "https://relay.example",
30+
}
31+
rt := NewRuntime(cfg, []string{"sh"}, nil)
32+
rt.WalletSecret = secret
33+
rt.WalletAddress = "WalletAddrBase58"
34+
35+
b64, err := rt.registryBlob()
36+
if err != nil {
37+
t.Fatalf("registryBlob: %v", err)
38+
}
39+
blob, err := base64.StdEncoding.DecodeString(b64)
40+
if err != nil {
41+
t.Fatalf("base64 decode: %v", err)
42+
}
43+
44+
key, err := identity.RegistryKey(secret)
45+
if err != nil {
46+
t.Fatalf("RegistryKey: %v", err)
47+
}
48+
pt, err := identity.OpenRecord(key, blob, cfg.MachineID)
49+
if err != nil {
50+
t.Fatalf("OpenRecord (right machine_id): %v", err)
51+
}
52+
var rec map[string]any
53+
if err := json.Unmarshal(pt, &rec); err != nil {
54+
t.Fatalf("record JSON: %v", err)
55+
}
56+
if rec["name"] != cfg.MachineName {
57+
t.Fatalf("record name = %v, want %q", rec["name"], cfg.MachineName)
58+
}
59+
if rec["host_pub"] != cfg.HostPubHex {
60+
t.Fatalf("record host_pub = %v, want %q", rec["host_pub"], cfg.HostPubHex)
61+
}
62+
if rec["signal_url"] != cfg.SignalURL {
63+
t.Fatalf("record signal_url = %v, want %q", rec["signal_url"], cfg.SignalURL)
64+
}
65+
66+
// AAD is machine_id: opening under a different machine_id must fail.
67+
if _, err := identity.OpenRecord(key, blob, "other-machine"); err == nil {
68+
t.Fatal("OpenRecord with wrong machine_id (AAD) should fail, but succeeded")
69+
}
70+
}
71+
72+
// TestRegistryBlobLegacyNoWallet proves a wallet-less Runtime never produces a
73+
// blob (legacy mir up publishes nothing).
74+
func TestRegistryBlobLegacyNoWallet(t *testing.T) {
75+
cfg := &Config{MachineID: "m1", MachineName: "legacy"}
76+
rt := NewRuntime(cfg, []string{"sh"}, nil)
77+
if _, err := rt.registryBlob(); err == nil {
78+
t.Fatal("registryBlob with no WalletSecret should error, but succeeded")
79+
}
80+
}
81+
82+
// TestServeOncePublishesRegistry proves that when serving the self-wallet owner,
83+
// the agent's FIRST message on the live registration is a TypeRegistry whose blob
84+
// opens to the device record. A fake relay captures the first frame.
85+
func TestServeOncePublishesRegistry(t *testing.T) {
86+
secret := bytes.Repeat([]byte{0x55}, 32)
87+
wallet := "SelfWalletBase58"
88+
89+
first := make(chan signal.SignalMsg, 1)
90+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91+
c, err := websocket.Accept(w, r, nil)
92+
if err != nil {
93+
return
94+
}
95+
_, data, err := c.Read(r.Context())
96+
if err != nil {
97+
return
98+
}
99+
var m signal.SignalMsg
100+
if json.Unmarshal(data, &m) == nil {
101+
first <- m
102+
}
103+
// hold the registration open until the test ends
104+
_, _, _ = c.Read(r.Context())
105+
}))
106+
defer srv.Close()
107+
108+
cfg := &Config{
109+
SignalURL: srv.URL,
110+
MachineID: "machine-pub-1",
111+
MachineName: "publisher",
112+
HostPubHex: "0011223344556677",
113+
PairedOwners: []string{wallet},
114+
}
115+
rt := NewRuntime(cfg, []string{"sh"}, nil)
116+
rt.WalletSecret = secret
117+
rt.WalletAddress = wallet
118+
119+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
120+
defer cancel()
121+
go func() { _, _, _ = rt.serveOnce(ctx, wallet) }()
122+
123+
select {
124+
case m := <-first:
125+
if m.Type != signal.TypeRegistry {
126+
t.Fatalf("first message type = %q, want %q", m.Type, signal.TypeRegistry)
127+
}
128+
blob, err := base64.StdEncoding.DecodeString(m.Registry)
129+
if err != nil {
130+
t.Fatalf("registry base64: %v", err)
131+
}
132+
key, err := identity.RegistryKey(secret)
133+
if err != nil {
134+
t.Fatalf("RegistryKey: %v", err)
135+
}
136+
pt, err := identity.OpenRecord(key, blob, cfg.MachineID)
137+
if err != nil {
138+
t.Fatalf("OpenRecord: %v", err)
139+
}
140+
var rec map[string]any
141+
if err := json.Unmarshal(pt, &rec); err != nil {
142+
t.Fatalf("record JSON: %v", err)
143+
}
144+
if rec["name"] != cfg.MachineName || rec["host_pub"] != cfg.HostPubHex {
145+
t.Fatalf("record = %v, want name=%q host_pub=%q", rec, cfg.MachineName, cfg.HostPubHex)
146+
}
147+
case <-ctx.Done():
148+
t.Fatal("relay never received the first registry message")
149+
}
150+
}
151+
152+
// TestServeOnceNoPublishForOtherOwner proves the agent does NOT publish a registry
153+
// blob when serving an owner that is not its own wallet (it lacks that wallet's
154+
// K_reg). For a non-self owner the first frame must not be a registry message.
155+
func TestServeOnceNoPublishForOtherOwner(t *testing.T) {
156+
secret := bytes.Repeat([]byte{0x55}, 32)
157+
158+
got := make(chan string, 1) // first message type, or "" if the conn closed without one
159+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160+
c, err := websocket.Accept(w, r, nil)
161+
if err != nil {
162+
return
163+
}
164+
typ := ""
165+
_, data, err := c.Read(r.Context())
166+
if err == nil {
167+
var m signal.SignalMsg
168+
if json.Unmarshal(data, &m) == nil {
169+
typ = m.Type
170+
}
171+
}
172+
got <- typ
173+
_, _, _ = c.Read(r.Context())
174+
}))
175+
defer srv.Close()
176+
177+
cfg := &Config{
178+
SignalURL: srv.URL,
179+
MachineID: "machine-pub-1",
180+
MachineName: "publisher",
181+
HostPubHex: "0011223344556677",
182+
PairedOwners: []string{"OtherOwner", "SelfWallet"},
183+
}
184+
rt := NewRuntime(cfg, []string{"sh"}, nil)
185+
rt.WalletSecret = secret
186+
rt.WalletAddress = "SelfWallet"
187+
188+
ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
189+
defer cancel()
190+
go func() { _, _, _ = rt.serveOnce(ctx, "OtherOwner") }()
191+
192+
select {
193+
case typ := <-got:
194+
if typ == signal.TypeRegistry {
195+
t.Fatal("agent published a registry blob for a non-self owner")
196+
}
197+
case <-ctx.Done():
198+
// no message at all is also correct (the agent only sends on offers).
199+
}
200+
}

go/internal/agent/runtime.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package agent
33

44
import (
55
"context"
6+
cryptorand "crypto/rand"
7+
"encoding/base64"
68
"encoding/hex"
79
"encoding/json"
810
"errors"
@@ -76,6 +78,15 @@ type Runtime struct {
7678
Logf func(string, ...any) // optional reconnect/status log (set by the CLI)
7779

7880
DisableLAN bool // when set, mir up serves the relay only (no QUIC listener / mDNS advertise)
81+
82+
// WalletSecret is the 32-byte prf secret of THIS machine's wallet (nil =
83+
// legacy/no wallet). It derives K_reg, the key under which the agent seals its
84+
// encrypted device registry record. Never sent to the relay.
85+
WalletSecret []byte
86+
// WalletAddress is this machine's base58 wallet. The agent publishes a registry
87+
// record on the live registration for this owner so your other devices discover
88+
// it; it publishes only for this self-wallet (it has no other wallet's K_reg).
89+
WalletAddress string
7990
}
8091

8192
// admit reserves a slot for a new attach handshake, returning false immediately
@@ -223,6 +234,43 @@ func (rt *Runtime) serveOwner(ctx context.Context, owner string) {
223234
}
224235
}
225236

237+
// registryBlob builds and AEAD-seals this machine's device registry record under
238+
// K_reg (derived from the wallet secret), returning base64(nonce||ciphertext||tag).
239+
// The record — {v, name, host_pub, signal_url, ts} — lets your other devices
240+
// discover this machine by name with no pairing. The relay never parses it (it's
241+
// encrypted and opaque); only a wallet-holder can open it, so plain json.Marshal
242+
// of the map is fine. A fresh random nonce per call keeps reconnect re-publishes
243+
// safe. Errors when there is no wallet (legacy mir up publishes nothing).
244+
func (rt *Runtime) registryBlob() (string, error) {
245+
if len(rt.WalletSecret) == 0 {
246+
return "", fmt.Errorf("registry: no wallet secret")
247+
}
248+
rec := map[string]any{
249+
"v": 1,
250+
"name": rt.cfg.MachineName,
251+
"host_pub": rt.cfg.HostPubHex,
252+
"signal_url": rt.cfg.SignalURL,
253+
"ts": time.Now().Unix(),
254+
}
255+
pt, err := json.Marshal(rec)
256+
if err != nil {
257+
return "", err
258+
}
259+
key, err := identity.RegistryKey(rt.WalletSecret)
260+
if err != nil {
261+
return "", err
262+
}
263+
nonce := make([]byte, 12)
264+
if _, err := cryptorand.Read(nonce); err != nil {
265+
return "", err
266+
}
267+
blob, err := identity.SealRecord(key, nonce, pt, rt.cfg.MachineID)
268+
if err != nil {
269+
return "", err
270+
}
271+
return base64.StdEncoding.EncodeToString(blob), nil
272+
}
273+
226274
// serveOnce dials the signaling channel for one owner and serves offers until
227275
// the connection drops. It returns:
228276
// - dialed: whether the dial itself succeeded (vs. relay down).
@@ -246,6 +294,18 @@ func (rt *Runtime) serveOnce(ctx context.Context, owner string) (dialed bool, up
246294
rt.Logf("event=connected owner=%s", short(owner))
247295
}
248296

297+
// Publish our encrypted device registry record as the first message, but ONLY
298+
// for our own wallet (we hold no other wallet's K_reg). It rides this live
299+
// registration; the relay holds it opaquely and serves it to your other
300+
// devices. Re-publishing on every reconnect is correct (fresh nonce + ts).
301+
if owner == rt.WalletAddress && len(rt.WalletSecret) > 0 {
302+
if blob, err := rt.registryBlob(); err == nil {
303+
if msg, err := json.Marshal(signal.SignalMsg{Type: signal.TypeRegistry, Registry: blob}); err == nil {
304+
_ = c.Write(ctx, websocket.MessageText, msg)
305+
}
306+
}
307+
}
308+
249309
for {
250310
_, data, err := c.Read(ctx)
251311
if err != nil {

go/internal/cli/agent_cmds.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ func (a *app) cmdUp(args []string) error {
7979

8080
rt := agent.NewRuntime(cfg, launch, ice())
8181
rt.DisableLAN = *noLAN
82+
// Wallet-rooted machines auto-serve their own wallet (no pairing for your own
83+
// devices) and publish an encrypted registry record. Legacy (wallet-less) mir
84+
// up is unchanged: it serves PairedOwners and publishes nothing.
85+
if err := a.applyWalletToUp(*dir, rt); err != nil {
86+
return err
87+
}
8288
// Structured, timestamped agent log. RFC3339-ish date+time in UTC plus the
8389
// binary prefix turns a bare "owner … disconnected" line into something you
8490
// can correlate against relay logs and tell a flap (low uptime) from a normal
@@ -97,6 +103,28 @@ func (a *app) cmdUp(args []string) error {
97103
return nil
98104
}
99105

106+
// applyWalletToUp wires this machine's wallet into the serving Runtime. On a
107+
// wallet-rooted identity it auto-pins the machine's OWN wallet as a served owner
108+
// (so your own devices attach with no SAS/pairing — B1.4 bindings) and hands the
109+
// wallet secret + address to the Runtime so it can seal + publish its encrypted
110+
// registry record on the live registration. A wallet-less (legacy) identity is a
111+
// no-op: `mir up` keeps today's behavior (serve PairedOwners, publish nothing).
112+
// PinOwner writes config.json's PairedOwners (the agent hot-reloads owners; pinning
113+
// before Up() puts it in the initial set). Any pin failure aborts so we never serve
114+
// in a half-configured state.
115+
func (a *app) applyWalletToUp(dir string, rt *agent.Runtime) error {
116+
idn, err := a.identity(dir)
117+
if err != nil || !idn.HasWallet() {
118+
return nil // legacy / no wallet: unchanged behavior
119+
}
120+
if err := agent.PinOwner(dir, idn.WalletAddress); err != nil {
121+
return err
122+
}
123+
rt.WalletSecret = idn.Secret()
124+
rt.WalletAddress = idn.WalletAddress
125+
return nil
126+
}
127+
100128
// autoUpdateLoop checks for a newer release every 12h and applies it only when no
101129
// owner session is active, then re-execs into the new binary (preserving PID/FDs
102130
// so a systemd/supervisor wrapper survives). Opt-in via --auto-update / MIR_AUTO_UPDATE.

0 commit comments

Comments
 (0)