diff --git a/README.md b/README.md
index e988d23..22de9d1 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,15 @@ mir attach laptop macmini linux
Everything defaults to the hosted relay + STUN, so no flags are needed. Point at your
own infrastructure with `--signal` / `MIR_SIGNAL` and `--stun` / `MIR_STUN`.
+**Your machines appear automatically.** Once they share your wallet (passkey-sync, or
+`mir wallet import-phrase` on a new machine), `mir up` publishes an **encrypted** record to
+the relay and your machines show up by name in `mir list` and the browser — no
+`mir add-machine`, no pairing between your own devices. The relay only ever holds opaque
+blobs it can't read; only your wallet decrypts them, and a forged record simply fails to
+open. A new machine prints a one-line "new device joined" notice. It's online-discovery:
+a powered-off machine reappears when it comes back; to retire one, turn it off (or, if a
+device is compromised, rotate with `mir keygen --wallet`).
+
**LAN-direct (no relay on the same network).** When the client and the machine are on
the same LAN, `mir attach` finds it over mDNS and connects straight over QUIC — no relay
round-trip. It's automatic and falls back to the relay within ~0.6 s if there's no local
diff --git a/SECURITY.md b/SECURITY.md
index 5cf2ea7..616315a 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -107,6 +107,18 @@ the network is hostile — provided the trust roots below are intact.
(b) the mDNS advertisement reveals that a Miranda node with a given `machine_id`
exists on the LAN. Disable both with `mir up --no-lan`; skip LAN discovery on a
client with `mir attach --relay-only`.
+- **Device registry (auto-discovery).** `mir up` publishes a device record so your other
+ devices find this machine by name with no manual pairing. The record (`name`, `host_pub`,
+ `signal_url`) is **encrypted** with a key derived from your wallet (ChaCha20-Poly1305,
+ HKDF of the wallet secret) before it leaves the machine, and the relay holds it **only
+ in-memory**, tied to the live registration — **no persistence, no database**. The relay
+ sees an opaque blob and which `machine_id`s are live under a wallet (the same linkability
+ it already has at attach); it **cannot read the record, and cannot forge one** — a record
+ sealed by anyone without your wallet fails to decrypt and is dropped by your devices, so
+ the AEAD is the authenticity check (no relay verification needed). The blob is bound to
+ its `machine_id` (AEAD associated data), so the relay can't even shuffle records between
+ slots. Discovery is online-only; "revocation" is powering a device off (it stops
+ registering) or, for a leaked phrase, rotating the wallet.
- **Compromised endpoints / Keychain.** Out of scope — the same trust you already
place in your own devices.
diff --git a/go/internal/agent/registry_publish_test.go b/go/internal/agent/registry_publish_test.go
new file mode 100644
index 0000000..bca0414
--- /dev/null
+++ b/go/internal/agent/registry_publish_test.go
@@ -0,0 +1,200 @@
+// go/internal/agent/registry_publish_test.go
+package agent
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/srcful/terminal-relay/go/internal/identity"
+ "github.com/srcful/terminal-relay/go/internal/signal"
+)
+
+// TestRegistryBlobOpens proves the agent seals a device record under K_reg that a
+// wallet-holder can open: registryBlob() returns base64(nonce||ct) which, decoded
+// and run through OpenRecord with the wallet's K_reg and the machine_id AAD, yields
+// the {name,host_pub,...} JSON. A wrong machine_id (AAD) must fail to open.
+func TestRegistryBlobOpens(t *testing.T) {
+ secret := bytes.Repeat([]byte{0x42}, 32)
+ cfg := &Config{
+ MachineID: "machine-xyz",
+ MachineName: "fredde-laptop",
+ HostPubHex: "deadbeefcafef00d",
+ SignalURL: "https://relay.example",
+ }
+ rt := NewRuntime(cfg, []string{"sh"}, nil)
+ rt.WalletSecret = secret
+ rt.WalletAddress = "WalletAddrBase58"
+
+ b64, err := rt.registryBlob()
+ if err != nil {
+ t.Fatalf("registryBlob: %v", err)
+ }
+ blob, err := base64.StdEncoding.DecodeString(b64)
+ if err != nil {
+ t.Fatalf("base64 decode: %v", err)
+ }
+
+ key, err := identity.RegistryKey(secret)
+ if err != nil {
+ t.Fatalf("RegistryKey: %v", err)
+ }
+ pt, err := identity.OpenRecord(key, blob, cfg.MachineID)
+ if err != nil {
+ t.Fatalf("OpenRecord (right machine_id): %v", err)
+ }
+ var rec map[string]any
+ if err := json.Unmarshal(pt, &rec); err != nil {
+ t.Fatalf("record JSON: %v", err)
+ }
+ if rec["name"] != cfg.MachineName {
+ t.Fatalf("record name = %v, want %q", rec["name"], cfg.MachineName)
+ }
+ if rec["host_pub"] != cfg.HostPubHex {
+ t.Fatalf("record host_pub = %v, want %q", rec["host_pub"], cfg.HostPubHex)
+ }
+ if rec["signal_url"] != cfg.SignalURL {
+ t.Fatalf("record signal_url = %v, want %q", rec["signal_url"], cfg.SignalURL)
+ }
+
+ // AAD is machine_id: opening under a different machine_id must fail.
+ if _, err := identity.OpenRecord(key, blob, "other-machine"); err == nil {
+ t.Fatal("OpenRecord with wrong machine_id (AAD) should fail, but succeeded")
+ }
+}
+
+// TestRegistryBlobLegacyNoWallet proves a wallet-less Runtime never produces a
+// blob (legacy mir up publishes nothing).
+func TestRegistryBlobLegacyNoWallet(t *testing.T) {
+ cfg := &Config{MachineID: "m1", MachineName: "legacy"}
+ rt := NewRuntime(cfg, []string{"sh"}, nil)
+ if _, err := rt.registryBlob(); err == nil {
+ t.Fatal("registryBlob with no WalletSecret should error, but succeeded")
+ }
+}
+
+// TestServeOncePublishesRegistry proves that when serving the self-wallet owner,
+// the agent's FIRST message on the live registration is a TypeRegistry whose blob
+// opens to the device record. A fake relay captures the first frame.
+func TestServeOncePublishesRegistry(t *testing.T) {
+ secret := bytes.Repeat([]byte{0x55}, 32)
+ wallet := "SelfWalletBase58"
+
+ first := make(chan signal.SignalMsg, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ return
+ }
+ _, data, err := c.Read(r.Context())
+ if err != nil {
+ return
+ }
+ var m signal.SignalMsg
+ if json.Unmarshal(data, &m) == nil {
+ first <- m
+ }
+ // hold the registration open until the test ends
+ _, _, _ = c.Read(r.Context())
+ }))
+ defer srv.Close()
+
+ cfg := &Config{
+ SignalURL: srv.URL,
+ MachineID: "machine-pub-1",
+ MachineName: "publisher",
+ HostPubHex: "0011223344556677",
+ PairedOwners: []string{wallet},
+ }
+ rt := NewRuntime(cfg, []string{"sh"}, nil)
+ rt.WalletSecret = secret
+ rt.WalletAddress = wallet
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ go func() { _, _, _ = rt.serveOnce(ctx, wallet) }()
+
+ select {
+ case m := <-first:
+ if m.Type != signal.TypeRegistry {
+ t.Fatalf("first message type = %q, want %q", m.Type, signal.TypeRegistry)
+ }
+ blob, err := base64.StdEncoding.DecodeString(m.Registry)
+ if err != nil {
+ t.Fatalf("registry base64: %v", err)
+ }
+ key, err := identity.RegistryKey(secret)
+ if err != nil {
+ t.Fatalf("RegistryKey: %v", err)
+ }
+ pt, err := identity.OpenRecord(key, blob, cfg.MachineID)
+ if err != nil {
+ t.Fatalf("OpenRecord: %v", err)
+ }
+ var rec map[string]any
+ if err := json.Unmarshal(pt, &rec); err != nil {
+ t.Fatalf("record JSON: %v", err)
+ }
+ if rec["name"] != cfg.MachineName || rec["host_pub"] != cfg.HostPubHex {
+ t.Fatalf("record = %v, want name=%q host_pub=%q", rec, cfg.MachineName, cfg.HostPubHex)
+ }
+ case <-ctx.Done():
+ t.Fatal("relay never received the first registry message")
+ }
+}
+
+// TestServeOnceNoPublishForOtherOwner proves the agent does NOT publish a registry
+// blob when serving an owner that is not its own wallet (it lacks that wallet's
+// K_reg). For a non-self owner the first frame must not be a registry message.
+func TestServeOnceNoPublishForOtherOwner(t *testing.T) {
+ secret := bytes.Repeat([]byte{0x55}, 32)
+
+ got := make(chan string, 1) // first message type, or "" if the conn closed without one
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ return
+ }
+ typ := ""
+ _, data, err := c.Read(r.Context())
+ if err == nil {
+ var m signal.SignalMsg
+ if json.Unmarshal(data, &m) == nil {
+ typ = m.Type
+ }
+ }
+ got <- typ
+ _, _, _ = c.Read(r.Context())
+ }))
+ defer srv.Close()
+
+ cfg := &Config{
+ SignalURL: srv.URL,
+ MachineID: "machine-pub-1",
+ MachineName: "publisher",
+ HostPubHex: "0011223344556677",
+ PairedOwners: []string{"OtherOwner", "SelfWallet"},
+ }
+ rt := NewRuntime(cfg, []string{"sh"}, nil)
+ rt.WalletSecret = secret
+ rt.WalletAddress = "SelfWallet"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
+ defer cancel()
+ go func() { _, _, _ = rt.serveOnce(ctx, "OtherOwner") }()
+
+ select {
+ case typ := <-got:
+ if typ == signal.TypeRegistry {
+ t.Fatal("agent published a registry blob for a non-self owner")
+ }
+ case <-ctx.Done():
+ // no message at all is also correct (the agent only sends on offers).
+ }
+}
diff --git a/go/internal/agent/runtime.go b/go/internal/agent/runtime.go
index 90a8468..c7b7aa5 100644
--- a/go/internal/agent/runtime.go
+++ b/go/internal/agent/runtime.go
@@ -3,6 +3,8 @@ package agent
import (
"context"
+ cryptorand "crypto/rand"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -76,6 +78,15 @@ type Runtime struct {
Logf func(string, ...any) // optional reconnect/status log (set by the CLI)
DisableLAN bool // when set, mir up serves the relay only (no QUIC listener / mDNS advertise)
+
+ // WalletSecret is the 32-byte prf secret of THIS machine's wallet (nil =
+ // legacy/no wallet). It derives K_reg, the key under which the agent seals its
+ // encrypted device registry record. Never sent to the relay.
+ WalletSecret []byte
+ // WalletAddress is this machine's base58 wallet. The agent publishes a registry
+ // record on the live registration for this owner so your other devices discover
+ // it; it publishes only for this self-wallet (it has no other wallet's K_reg).
+ WalletAddress string
}
// 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) {
}
}
+// registryBlob builds and AEAD-seals this machine's device registry record under
+// K_reg (derived from the wallet secret), returning base64(nonce||ciphertext||tag).
+// The record — {v, name, host_pub, signal_url, ts} — lets your other devices
+// discover this machine by name with no pairing. The relay never parses it (it's
+// encrypted and opaque); only a wallet-holder can open it, so plain json.Marshal
+// of the map is fine. A fresh random nonce per call keeps reconnect re-publishes
+// safe. Errors when there is no wallet (legacy mir up publishes nothing).
+func (rt *Runtime) registryBlob() (string, error) {
+ if len(rt.WalletSecret) == 0 {
+ return "", fmt.Errorf("registry: no wallet secret")
+ }
+ rec := map[string]any{
+ "v": 1,
+ "name": rt.cfg.MachineName,
+ "host_pub": rt.cfg.HostPubHex,
+ "signal_url": rt.cfg.SignalURL,
+ "ts": time.Now().Unix(),
+ }
+ pt, err := json.Marshal(rec)
+ if err != nil {
+ return "", err
+ }
+ key, err := identity.RegistryKey(rt.WalletSecret)
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, 12)
+ if _, err := cryptorand.Read(nonce); err != nil {
+ return "", err
+ }
+ blob, err := identity.SealRecord(key, nonce, pt, rt.cfg.MachineID)
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(blob), nil
+}
+
// serveOnce dials the signaling channel for one owner and serves offers until
// the connection drops. It returns:
// - 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
rt.Logf("event=connected owner=%s", short(owner))
}
+ // Publish our encrypted device registry record as the first message, but ONLY
+ // for our own wallet (we hold no other wallet's K_reg). It rides this live
+ // registration; the relay holds it opaquely and serves it to your other
+ // devices. Re-publishing on every reconnect is correct (fresh nonce + ts).
+ if owner == rt.WalletAddress && len(rt.WalletSecret) > 0 {
+ if blob, err := rt.registryBlob(); err == nil {
+ if msg, err := json.Marshal(signal.SignalMsg{Type: signal.TypeRegistry, Registry: blob}); err == nil {
+ _ = c.Write(ctx, websocket.MessageText, msg)
+ }
+ }
+ }
+
for {
_, data, err := c.Read(ctx)
if err != nil {
diff --git a/go/internal/cli/agent_cmds.go b/go/internal/cli/agent_cmds.go
index 70c3bb3..f4b9921 100644
--- a/go/internal/cli/agent_cmds.go
+++ b/go/internal/cli/agent_cmds.go
@@ -79,6 +79,12 @@ func (a *app) cmdUp(args []string) error {
rt := agent.NewRuntime(cfg, launch, ice())
rt.DisableLAN = *noLAN
+ // Wallet-rooted machines auto-serve their own wallet (no pairing for your own
+ // devices) and publish an encrypted registry record. Legacy (wallet-less) mir
+ // up is unchanged: it serves PairedOwners and publishes nothing.
+ if err := a.applyWalletToUp(*dir, rt); err != nil {
+ return err
+ }
// Structured, timestamped agent log. RFC3339-ish date+time in UTC plus the
// binary prefix turns a bare "owner … disconnected" line into something you
// 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 {
return nil
}
+// applyWalletToUp wires this machine's wallet into the serving Runtime. On a
+// wallet-rooted identity it auto-pins the machine's OWN wallet as a served owner
+// (so your own devices attach with no SAS/pairing — B1.4 bindings) and hands the
+// wallet secret + address to the Runtime so it can seal + publish its encrypted
+// registry record on the live registration. A wallet-less (legacy) identity is a
+// no-op: `mir up` keeps today's behavior (serve PairedOwners, publish nothing).
+// PinOwner writes config.json's PairedOwners (the agent hot-reloads owners; pinning
+// before Up() puts it in the initial set). Any pin failure aborts so we never serve
+// in a half-configured state.
+func (a *app) applyWalletToUp(dir string, rt *agent.Runtime) error {
+ idn, err := a.identity(dir)
+ if err != nil || !idn.HasWallet() {
+ return nil // legacy / no wallet: unchanged behavior
+ }
+ if err := agent.PinOwner(dir, idn.WalletAddress); err != nil {
+ return err
+ }
+ rt.WalletSecret = idn.Secret()
+ rt.WalletAddress = idn.WalletAddress
+ return nil
+}
+
// autoUpdateLoop checks for a newer release every 12h and applies it only when no
// owner session is active, then re-execs into the new binary (preserving PID/FDs
// so a systemd/supervisor wrapper survives). Opt-in via --auto-update / MIR_AUTO_UPDATE.
diff --git a/go/internal/cli/agent_up_wallet_test.go b/go/internal/cli/agent_up_wallet_test.go
new file mode 100644
index 0000000..78f182d
--- /dev/null
+++ b/go/internal/cli/agent_up_wallet_test.go
@@ -0,0 +1,59 @@
+package cli
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/srcful/terminal-relay/go/internal/agent"
+)
+
+// TestUpAutoPinsOwnWallet proves the wallet block in `mir up` auto-pins the
+// machine's own wallet as a served owner (so your own devices need no pairing)
+// and hands the wallet secret to the Runtime so it can publish the registry
+// record. A wallet-rooted identity must end up pinned; the Runtime must carry the
+// wallet.
+func TestUpAutoPinsOwnWallet(t *testing.T) {
+ dir := t.TempDir()
+ var out, errb bytes.Buffer
+ a := &app{out: &out, errOut: &errb, binary: "mir"}
+
+ // Enroll the agent so config.json exists (PinOwner reads/writes it).
+ cfg, err := agent.LoadOrInit(dir, "test-machine", "https://relay.example")
+ if err != nil {
+ t.Fatalf("LoadOrInit: %v", err)
+ }
+ rt := agent.NewRuntime(cfg, []string{"sh"}, nil)
+
+ // The unit under test: load the wallet, auto-pin it, wire it into the Runtime.
+ if err := a.applyWalletToUp(dir, rt); err != nil {
+ t.Fatalf("applyWalletToUp: %v", err)
+ }
+
+ idn, err := a.identity(dir)
+ if err != nil {
+ t.Fatalf("identity: %v", err)
+ }
+ if !idn.HasWallet() {
+ t.Fatal("fresh identity should be wallet-rooted")
+ }
+ if pinned, err := agent.ReloadOwners(dir); err != nil {
+ t.Fatalf("ReloadOwners: %v", err)
+ } else if !contains(pinned, idn.WalletAddress) {
+ t.Fatalf("own wallet %s not pinned; owners = %v", idn.WalletAddress, pinned)
+ }
+ if rt.WalletAddress != idn.WalletAddress {
+ t.Fatalf("rt.WalletAddress = %q, want %q", rt.WalletAddress, idn.WalletAddress)
+ }
+ if len(rt.WalletSecret) == 0 {
+ t.Fatal("rt.WalletSecret not set; Runtime cannot publish the registry record")
+ }
+}
+
+func contains(ss []string, want string) bool {
+ for _, s := range ss {
+ if s == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/go/internal/cli/cli_test.go b/go/internal/cli/cli_test.go
index e1b4587..956c59d 100644
--- a/go/internal/cli/cli_test.go
+++ b/go/internal/cli/cli_test.go
@@ -106,6 +106,10 @@ func TestKeygenPrintsOwnerKey(t *testing.T) {
func TestListEmptyThenAddMachine(t *testing.T) {
t.Setenv("MIR_NO_UPDATE_CHECK", "1")
+ // list now fetches the wallet registry on the default relay; point it at a dead
+ // local address so the unit test stays hermetic (FetchRegistry fails fast and is
+ // best-effort, so list still falls back to the local machines.json).
+ t.Setenv("MIR_SIGNAL", "http://127.0.0.1:1")
dir := t.TempDir()
var out, errb bytes.Buffer
if code := Run([]string{"list", "--dir", dir}, &out, &errb); code != 0 {
diff --git a/go/internal/cli/client_cmds.go b/go/internal/cli/client_cmds.go
index ca8ce9d..b023829 100644
--- a/go/internal/cli/client_cmds.go
+++ b/go/internal/cli/client_cmds.go
@@ -171,16 +171,44 @@ func (a *app) cmdList(args []string) error {
_ = fs.Parse(args)
// Cheap, non-blocking update notice (cache-only display; refresh in background).
selfupdate.New(repoSlug, a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour)
- list, err := client.ListMachines(*dir)
+ local, err := client.ListMachines(*dir)
if err != nil {
return err
}
- if len(list) == 0 {
+
+ // Discover your own machines from the relay's encrypted registry. Best-effort:
+ // a wallet-less identity or a relay hiccup just falls back to the local list.
+ // The registry is keyed by wallet on the default relay (the one agents register
+ // with), so fetch there regardless of any per-machine SignalURL.
+ idn, err := a.identity(*dir)
+ if err != nil {
+ return err
+ }
+ var discovered []client.Machine
+ discoveredID := map[string]bool{}
+ if idn.HasWallet() {
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ disc, _ := client.FetchRegistry(ctx, nil, defaults.SignalURL(), idn)
+ cancel()
+ discovered = disc
+ for _, m := range disc {
+ discoveredID[m.MachineID] = true
+ }
+ // One-line "new device joined" notice on stderr, so stdout stays script-clean.
+ _ = client.NotifyNewDevices(a.errOut, *dir, disc)
+ }
+
+ merged := client.MergeMachines(local, discovered)
+ if len(merged) == 0 {
fmt.Fprintln(a.out, "no machines yet — add one with `mir add-machine`")
return nil
}
- for _, m := range list {
- fmt.Fprintf(a.out, "%-16s %s %s\n", m.Name, m.MachineID, m.SignalURL)
+ for _, m := range merged {
+ tag := ""
+ if discoveredID[m.MachineID] {
+ tag = " (online)"
+ }
+ fmt.Fprintf(a.out, "%-16s %s %s%s\n", m.Name, m.MachineID, m.SignalURL, tag)
}
return nil
}
@@ -194,6 +222,26 @@ func isCleanDetach(err error) bool {
return errors.Is(err, peer.ErrDataChannelClosed) || errors.Is(err, io.EOF)
}
+// resolveFromRegistry looks up a machine by name in the wallet's encrypted relay
+// registry when it isn't pinned locally. The returned Machine is trusted: its
+// host_pub was sealed under your wallet, so attaching needs no add-machine. If the
+// machine isn't in the registry either, it returns an "unknown machine" error that
+// hints it may simply be offline (the registry only lists live agents).
+func (a *app) resolveFromRegistry(ctx context.Context, dir, name string, idn *client.Identity) (*client.Machine, error) {
+ if idn.HasWallet() {
+ fctx, cancel := context.WithTimeout(ctx, 8*time.Second)
+ disc, _ := client.FetchRegistry(fctx, nil, defaults.SignalURL(), idn)
+ cancel()
+ _ = client.NotifyNewDevices(a.errOut, dir, disc)
+ for i := range disc {
+ if disc[i].Name == name {
+ return &disc[i], nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("unknown machine %q — not paired locally and no live device by that name on your wallet (it may be offline)", name)
+}
+
func (a *app) cmdAttach(args []string) error {
fs := flag.NewFlagSet("attach", flag.ExitOnError)
dir := fs.String("dir", defaultDir(), "config directory")
@@ -227,7 +275,14 @@ func (a *app) cmdAttach(args []string) error {
if len(names) == 1 {
m, err := client.GetMachine(*dir, names[0])
if err != nil {
- return err
+ // Not pinned locally — fall back to the wallet registry. A registry hit
+ // is wallet-authenticated (its host_pub came sealed under your wallet),
+ // so it's trusted: no add-machine needed for your own devices.
+ rm, rerr := a.resolveFromRegistry(ctx, *dir, names[0], idn)
+ if rerr != nil {
+ return rerr
+ }
+ m = rm
}
mc, sess, cleanup, err := client.Attach(ctx, *m, idn, servers, *relayOnly)
if err != nil {
diff --git a/go/internal/client/registry.go b/go/internal/client/registry.go
new file mode 100644
index 0000000..8d8abdf
--- /dev/null
+++ b/go/internal/client/registry.go
@@ -0,0 +1,188 @@
+// go/internal/client/registry.go
+package client
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ neturl "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/srcful/terminal-relay/go/internal/identity"
+)
+
+// registryEntry is the relay's blind wire shape: GET /registry?wallet=W ->
+// [{machine_id, blob}]. blob is base64(nonce||ciphertext||tag), sealed by a
+// wallet-holding agent (AAD = machine_id). The relay never opens it.
+type registryEntry struct {
+ MachineID string `json:"machine_id"`
+ Blob string `json:"blob"`
+}
+
+// registryRecord is the sealed plaintext an agent publishes: {v, name, host_pub,
+// signal_url, ts}. Only the wallet-holder can open the blob to recover it.
+type registryRecord struct {
+ Name string `json:"name"`
+ HostPub string `json:"host_pub"`
+ SignalURL string `json:"signal_url"`
+}
+
+// FetchRegistry asks the relay for this wallet's live device records and decrypts
+// them. Forged/garbage blobs (sealed by a non-wallet-holder) fail to open and are
+// silently dropped. Best-effort: a relay error or a wallet-less identity returns
+// nil so callers can fall back to the local machines.json without surfacing noise.
+func FetchRegistry(ctx context.Context, hc *http.Client, signalURL string, id *Identity) ([]Machine, error) {
+ if !id.HasWallet() {
+ return nil, nil
+ }
+ key, err := identity.RegistryKey(id.Secret())
+ if err != nil {
+ return nil, err
+ }
+ if hc == nil {
+ hc = &http.Client{Timeout: 8 * time.Second}
+ }
+ url := strings.TrimRight(signalURL, "/") + "/registry?wallet=" + neturl.QueryEscape(id.WalletAddress)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := hc.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("registry: relay returned %s", resp.Status)
+ }
+ var entries []registryEntry
+ if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
+ return nil, err
+ }
+
+ var machines []Machine
+ for _, e := range entries {
+ blob, err := base64.StdEncoding.DecodeString(e.Blob)
+ if err != nil {
+ continue // not even valid base64 — drop
+ }
+ pt, err := identity.OpenRecord(key, blob, e.MachineID)
+ if err != nil {
+ continue // forged/garbage/wrong-machine — drop the forgery
+ }
+ var rec registryRecord
+ if err := json.Unmarshal(pt, &rec); err != nil {
+ continue // opened but malformed — drop
+ }
+ su := rec.SignalURL
+ if su == "" {
+ su = signalURL // always attachable: fall back to the relay we fetched from
+ }
+ machines = append(machines, Machine{
+ Name: rec.Name,
+ MachineID: e.MachineID,
+ HostPubHex: rec.HostPub,
+ SignalURL: su,
+ })
+ }
+ return machines, nil
+}
+
+// MergeMachines unions local and discovered machines by MachineID. A machine
+// present locally keeps its local entry (local wins) — the user's pinned
+// machines.json is authoritative; discovered-only machines are appended. Order is
+// local-first, then the discovered newcomers, so existing list output is stable.
+func MergeMachines(local, discovered []Machine) []Machine {
+ seen := make(map[string]bool, len(local))
+ out := make([]Machine, 0, len(local)+len(discovered))
+ for _, m := range local {
+ seen[m.MachineID] = true
+ out = append(out, m)
+ }
+ for _, m := range discovered {
+ if seen[m.MachineID] {
+ continue // local wins
+ }
+ seen[m.MachineID] = true
+ out = append(out, m)
+ }
+ return out
+}
+
+// ResolveMachine finds a machine by name, preferring the local store, then the
+// discovered registry. It returns a copy and whether it came from discovery.
+func ResolveMachine(local, discovered []Machine, name string) (Machine, bool, bool) {
+ for _, m := range local {
+ if m.Name == name {
+ return m, true, false
+ }
+ }
+ for _, m := range discovered {
+ if m.Name == name {
+ return m, true, true
+ }
+ }
+ return Machine{}, false, false
+}
+
+func seenPath(dir string) string { return filepath.Join(dir, "seen.json") }
+
+type seenSet struct {
+ MachineIDs []string `json:"machine_ids"`
+}
+
+// NotifyNewDevices prints a one-line "new device joined" notice (to w) the first
+// time a machine_id is seen, then persists the union to
/seen.json so the
+// notice fires exactly once per device. It is pure-ish — the writer and dir are
+// injected — so the wiring stays trivially testable.
+func NotifyNewDevices(w io.Writer, dir string, machines []Machine) error {
+ seen := loadSeen(dir)
+ known := make(map[string]bool, len(seen.MachineIDs))
+ for _, id := range seen.MachineIDs {
+ known[id] = true
+ }
+ changed := false
+ for _, m := range machines {
+ if m.MachineID == "" || known[m.MachineID] {
+ continue
+ }
+ known[m.MachineID] = true
+ seen.MachineIDs = append(seen.MachineIDs, m.MachineID)
+ changed = true
+ fmt.Fprintf(w, "📣 new device %q joined your wallet\n", m.Name)
+ }
+ if !changed {
+ return nil
+ }
+ return saveSeen(dir, seen)
+}
+
+// loadSeen reads the seen-set; a missing or unreadable file is an empty set (so a
+// first run notifies for everything and a corrupt file degrades to re-notifying,
+// never to a hard error).
+func loadSeen(dir string) seenSet {
+ var s seenSet
+ data, err := os.ReadFile(seenPath(dir))
+ if err != nil {
+ return s
+ }
+ _ = json.Unmarshal(data, &s)
+ return s
+}
+
+func saveSeen(dir string, s seenSet) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(seenPath(dir), data, 0o600)
+}
diff --git a/go/internal/client/registry_test.go b/go/internal/client/registry_test.go
new file mode 100644
index 0000000..a93bbb1
--- /dev/null
+++ b/go/internal/client/registry_test.go
@@ -0,0 +1,248 @@
+package client
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/srcful/terminal-relay/go/internal/identity"
+)
+
+// sealEntry builds the relay's {machine_id, blob} wire entry for a record sealed
+// under the given secret's K_reg. This mirrors what a wallet-holding agent does.
+func sealEntry(t *testing.T, secret []byte, machineID, name, hostPub, signalURL string) struct {
+ MachineID string `json:"machine_id"`
+ Blob string `json:"blob"`
+} {
+ t.Helper()
+ key, err := identity.RegistryKey(secret)
+ if err != nil {
+ t.Fatalf("RegistryKey: %v", err)
+ }
+ rec := map[string]any{
+ "v": 1,
+ "name": name,
+ "host_pub": hostPub,
+ "signal_url": signalURL,
+ "ts": 1749600000,
+ }
+ pt, _ := json.Marshal(rec)
+ nonce := make([]byte, 12) // fixed nonce fine for a test
+ blob, err := identity.SealRecord(key, nonce, pt, machineID)
+ if err != nil {
+ t.Fatalf("SealRecord: %v", err)
+ }
+ return struct {
+ MachineID string `json:"machine_id"`
+ Blob string `json:"blob"`
+ }{MachineID: machineID, Blob: base64.StdEncoding.EncodeToString(blob)}
+}
+
+// walletIdentity builds a prf-rooted identity from a fixed secret so the test can
+// derive the same K_reg the fake relay sealed under.
+func walletIdentity(t *testing.T, secretHex string) *Identity {
+ t.Helper()
+ id := &Identity{}
+ secret := mustHex(t, secretHex)
+ if err := id.SetFromSecret(secret); err != nil {
+ t.Fatalf("SetFromSecret: %v", err)
+ }
+ return id
+}
+
+func mustHex(t *testing.T, s string) []byte {
+ t.Helper()
+ b, err := hex.DecodeString(s)
+ if err != nil {
+ t.Fatalf("bad hex: %v", err)
+ }
+ return b
+}
+
+const testSecretHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
+
+// TestFetchRegistryDecryptsOwnRecords: a record sealed under the same wallet
+// secret is fetched, opened, and returned as a Machine. A second entry sealed
+// under a DIFFERENT key fails to open and is silently dropped.
+func TestFetchRegistryDecryptsAndDropsForgeries(t *testing.T) {
+ id := walletIdentity(t, testSecretHex)
+
+ good := sealEntry(t, id.Secret(), "m-good", "kitchen", "aa11bb22", "wss://relay.example/agent")
+ // Forged: sealed under a different secret -> opens to garbage under our K_reg.
+ forged := sealEntry(t, mustHex(t, "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
+ "m-forged", "evil", "deadbeef", "wss://evil.example/agent")
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/registry" {
+ http.NotFound(w, r)
+ return
+ }
+ if got := r.URL.Query().Get("wallet"); got != id.WalletAddress {
+ t.Errorf("wallet query = %q, want %q", got, id.WalletAddress)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode([]any{good, forged})
+ }))
+ defer srv.Close()
+
+ got, err := FetchRegistry(context.Background(), nil, srv.URL, id)
+ if err != nil {
+ t.Fatalf("FetchRegistry: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("got %d machines, want 1 (forgery dropped): %+v", len(got), got)
+ }
+ m := got[0]
+ if m.Name != "kitchen" || m.MachineID != "m-good" || m.HostPubHex != "aa11bb22" {
+ t.Fatalf("decoded machine = %+v", m)
+ }
+ if m.SignalURL != "wss://relay.example/agent" {
+ t.Fatalf("signal_url = %q", m.SignalURL)
+ }
+}
+
+// A record whose sealed payload omits signal_url inherits the fetch signalURL,
+// so the returned Machine is always attachable.
+func TestFetchRegistryFallsBackToFetchSignalURL(t *testing.T) {
+ id := walletIdentity(t, testSecretHex)
+ e := sealEntry(t, id.Secret(), "m1", "box", "cc33", "") // empty signal_url in record
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode([]any{e})
+ }))
+ defer srv.Close()
+
+ got, err := FetchRegistry(context.Background(), nil, srv.URL, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].SignalURL != srv.URL {
+ t.Fatalf("expected fallback signal_url=%q, got %+v", srv.URL, got)
+ }
+}
+
+// A wallet-less (legacy) identity has no K_reg, so FetchRegistry is a no-op (nil,
+// nil) — it never even hits the relay.
+func TestFetchRegistryWalletlessIsNil(t *testing.T) {
+ legacy := &Identity{OwnerPrivHex: strings.Repeat("aa", 32), OwnerPubHex: strings.Repeat("bb", 32)}
+ hit := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hit = true
+ }))
+ defer srv.Close()
+
+ got, err := FetchRegistry(context.Background(), nil, srv.URL, legacy)
+ if err != nil {
+ t.Fatalf("wallet-less FetchRegistry should not error: %v", err)
+ }
+ if got != nil {
+ t.Fatalf("wallet-less FetchRegistry should return nil, got %+v", got)
+ }
+ if hit {
+ t.Fatal("wallet-less FetchRegistry must not contact the relay")
+ }
+}
+
+// A relay error (non-200, or unreachable) is best-effort: FetchRegistry returns
+// an error the caller ignores, never a panic, and never a partial list.
+func TestFetchRegistryRelayErrorIsBestEffort(t *testing.T) {
+ id := walletIdentity(t, testSecretHex)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ got, err := FetchRegistry(context.Background(), nil, srv.URL, id)
+ if err == nil {
+ t.Fatal("expected an error on a 500 relay")
+ }
+ if got != nil {
+ t.Fatalf("expected nil machines on relay error, got %+v", got)
+ }
+}
+
+func TestMergeMachines(t *testing.T) {
+ local := []Machine{
+ {Name: "box", MachineID: "m1", HostPubHex: "local11", SignalURL: "wss://local"},
+ {Name: "laptop", MachineID: "m2", HostPubHex: "local22", SignalURL: "wss://local"},
+ }
+ discovered := []Machine{
+ {Name: "box-renamed", MachineID: "m1", HostPubHex: "disc11", SignalURL: "wss://disc"}, // same id -> local wins
+ {Name: "kitchen", MachineID: "m3", HostPubHex: "disc33", SignalURL: "wss://disc"}, // new -> added
+ }
+
+ merged := MergeMachines(local, discovered)
+ if len(merged) != 3 {
+ t.Fatalf("merged len = %d, want 3: %+v", len(merged), merged)
+ }
+ byID := map[string]Machine{}
+ for _, m := range merged {
+ byID[m.MachineID] = m
+ }
+ // Local wins for m1.
+ if byID["m1"].Name != "box" || byID["m1"].HostPubHex != "local11" {
+ t.Fatalf("m1 should keep the local entry, got %+v", byID["m1"])
+ }
+ // Discovered-only added.
+ if byID["m3"].Name != "kitchen" || byID["m3"].HostPubHex != "disc33" {
+ t.Fatalf("m3 (discovered) should be added, got %+v", byID["m3"])
+ }
+ // Local-only preserved.
+ if byID["m2"].Name != "laptop" {
+ t.Fatalf("m2 (local-only) should be preserved, got %+v", byID["m2"])
+ }
+}
+
+func TestNotifyNewDevices(t *testing.T) {
+ dir := t.TempDir()
+ machines := []Machine{
+ {Name: "box", MachineID: "m1"},
+ {Name: "kitchen", MachineID: "m2"},
+ }
+
+ var b strings.Builder
+ if err := NotifyNewDevices(&b, dir, machines); err != nil {
+ t.Fatalf("NotifyNewDevices: %v", err)
+ }
+ first := b.String()
+ if !strings.Contains(first, `"box"`) || !strings.Contains(first, `"kitchen"`) {
+ t.Fatalf("first notify should name both new devices, got:\n%s", first)
+ }
+ if !strings.Contains(first, "new device") || !strings.Contains(first, "joined your wallet") {
+ t.Fatalf("first notify wording = %q", first)
+ }
+
+ // seen.json must now contain both ids.
+ if _, err := os.Stat(filepath.Join(dir, "seen.json")); err != nil {
+ t.Fatalf("seen.json not persisted: %v", err)
+ }
+
+ // Second call with the same ids prints nothing.
+ b.Reset()
+ if err := NotifyNewDevices(&b, dir, machines); err != nil {
+ t.Fatalf("NotifyNewDevices (2nd): %v", err)
+ }
+ if b.String() != "" {
+ t.Fatalf("second notify should be silent, got:\n%s", b.String())
+ }
+
+ // A genuinely new id fires once.
+ b.Reset()
+ machines = append(machines, Machine{Name: "garage", MachineID: "m3"})
+ if err := NotifyNewDevices(&b, dir, machines); err != nil {
+ t.Fatalf("NotifyNewDevices (3rd): %v", err)
+ }
+ if !strings.Contains(b.String(), `"garage"`) {
+ t.Fatalf("new device should fire, got:\n%s", b.String())
+ }
+ if strings.Contains(b.String(), `"box"`) || strings.Contains(b.String(), `"kitchen"`) {
+ t.Fatalf("already-seen devices should stay silent, got:\n%s", b.String())
+ }
+}
diff --git a/go/internal/identity/registry.go b/go/internal/identity/registry.go
new file mode 100644
index 0000000..ffdb685
--- /dev/null
+++ b/go/internal/identity/registry.go
@@ -0,0 +1,56 @@
+// go/internal/identity/registry.go
+package identity
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "io"
+
+ "golang.org/x/crypto/chacha20poly1305"
+ "golang.org/x/crypto/hkdf"
+)
+
+// registrySalt domain-separates the registry AEAD key from any other use of the
+// wallet secret. K_reg = HKDF-SHA256(wallet_secret, registrySalt, "aead-key").
+const registrySalt = "miranda/registry/v1"
+
+// RegistryKey derives the symmetric registry-encryption key from the wallet's
+// 32-byte prf secret. Only wallet-holders can derive it; the relay never sees it.
+// Mirrors web/src/identity/registry.js exactly.
+func RegistryKey(secret []byte) ([]byte, error) {
+ r := hkdf.New(sha256.New, secret, []byte(registrySalt), []byte("aead-key"))
+ k := make([]byte, chacha20poly1305.KeySize)
+ if _, err := io.ReadFull(r, k); err != nil {
+ return nil, err
+ }
+ return k, nil
+}
+
+// SealRecord encrypts plaintext under key with machineID as AEAD associated data,
+// returning nonce||ciphertext||tag. nonce must be 12 bytes (ChaCha20-Poly1305
+// IETF). The machineID binds the blob to its registry slot.
+func SealRecord(key, nonce, plaintext []byte, machineID string) ([]byte, error) {
+ aead, err := chacha20poly1305.New(key)
+ if err != nil {
+ return nil, err
+ }
+ if len(nonce) != aead.NonceSize() {
+ return nil, fmt.Errorf("registry: nonce must be %d bytes", aead.NonceSize())
+ }
+ ct := aead.Seal(nil, nonce, plaintext, []byte(machineID))
+ return append(append([]byte{}, nonce...), ct...), nil
+}
+
+// OpenRecord reverses SealRecord. It returns an error (never partial plaintext)
+// on any failure — a forged/garbage blob, or a wrong machineID (AAD), fails here.
+func OpenRecord(key, blob []byte, machineID string) ([]byte, error) {
+ aead, err := chacha20poly1305.New(key)
+ if err != nil {
+ return nil, err
+ }
+ n := aead.NonceSize()
+ if len(blob) < n {
+ return nil, fmt.Errorf("registry: short blob")
+ }
+ return aead.Open(nil, blob[:n], blob[n:], []byte(machineID))
+}
diff --git a/go/internal/identity/registry_test.go b/go/internal/identity/registry_test.go
new file mode 100644
index 0000000..f38c65f
--- /dev/null
+++ b/go/internal/identity/registry_test.go
@@ -0,0 +1,171 @@
+package identity
+
+import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// Fixed registry-vector inputs. secret is an arbitrary fixed 32-byte value (the
+// wallet prf root is 32 bytes; the vector pins the crypto, not the derivation).
+const (
+ regSecretHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
+ regNonceHex = "0102030405060708090a0b0c" // fixed 12-byte nonce
+ regMachineID = "a1b2c3d4e5f60718"
+ regRecord = `{"v":1,"name":"zap-kitchen","host_pub":"269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613","signal_url":"wss://signal.miranda.example/agent","ts":1749600000}`
+)
+
+func TestRegistryKeyDeterministic(t *testing.T) {
+ secret, _ := hex.DecodeString(regSecretHex)
+ k1, err := RegistryKey(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ k2, err := RegistryKey(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(k1, k2) {
+ t.Fatal("same secret produced different keys")
+ }
+ if len(k1) != 32 {
+ t.Fatalf("key length = %d, want 32", len(k1))
+ }
+ other, _ := hex.DecodeString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
+ k3, err := RegistryKey(other)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(k1, k3) {
+ t.Fatal("different secrets produced the same key")
+ }
+}
+
+func TestSealOpenRoundTrip(t *testing.T) {
+ secret, _ := hex.DecodeString(regSecretHex)
+ key, _ := RegistryKey(secret)
+ nonce, _ := hex.DecodeString(regNonceHex)
+ plaintext := []byte(regRecord)
+
+ blob, err := SealRecord(key, nonce, plaintext, regMachineID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(blob) <= len(nonce) {
+ t.Fatal("blob not longer than nonce")
+ }
+ if !bytes.Equal(blob[:len(nonce)], nonce) {
+ t.Fatal("blob does not start with the nonce")
+ }
+
+ got, err := OpenRecord(key, blob, regMachineID)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ if !bytes.Equal(got, plaintext) {
+ t.Fatalf("round-trip mismatch:\n got %q\nwant %q", got, plaintext)
+ }
+}
+
+func TestOpenRejectsWrongAAD(t *testing.T) {
+ secret, _ := hex.DecodeString(regSecretHex)
+ key, _ := RegistryKey(secret)
+ nonce, _ := hex.DecodeString(regNonceHex)
+ blob, err := SealRecord(key, nonce, []byte(regRecord), regMachineID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := OpenRecord(key, blob, "b1b2c3d4e5f60718"); err == nil {
+ t.Fatal("open with wrong machineID (AAD) should fail")
+ }
+}
+
+func TestOpenRejectsTamper(t *testing.T) {
+ secret, _ := hex.DecodeString(regSecretHex)
+ key, _ := RegistryKey(secret)
+ nonce, _ := hex.DecodeString(regNonceHex)
+ blob, err := SealRecord(key, nonce, []byte(regRecord), regMachineID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Flip one ciphertext byte (just past the 12-byte nonce prefix).
+ tampered := append([]byte{}, blob...)
+ tampered[len(nonce)] ^= 0x01
+ if _, err := OpenRecord(key, tampered, regMachineID); err == nil {
+ t.Fatal("open of a tampered blob should fail")
+ }
+ // A blob shorter than the nonce is rejected, not panicked.
+ if _, err := OpenRecord(key, nonce[:5], regMachineID); err == nil {
+ t.Fatal("open of a short blob should fail")
+ }
+}
+
+func registryVectorPath() string {
+ return filepath.Join("..", "..", "..", "testdata", "registry-vector.json")
+}
+
+type registryVector struct {
+ Secret string `json:"secret"` // hex, 32-byte ikm
+ Key string `json:"key"` // hex, derived K_reg
+ Nonce string `json:"nonce"` // hex, 12-byte
+ Record string `json:"record"` // JSON plaintext string
+ MachineID string `json:"machine_id"` // AEAD associated data
+ Blob string `json:"blob"` // hex, nonce||ciphertext||tag
+}
+
+func TestRegistryVector(t *testing.T) {
+ secret, _ := hex.DecodeString(regSecretHex)
+ nonce, _ := hex.DecodeString(regNonceHex)
+ key, err := RegistryKey(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ blob, err := SealRecord(key, nonce, []byte(regRecord), regMachineID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := registryVector{
+ Secret: regSecretHex,
+ Key: hex.EncodeToString(key),
+ Nonce: regNonceHex,
+ Record: regRecord,
+ MachineID: regMachineID,
+ Blob: hex.EncodeToString(blob),
+ }
+
+ path := registryVectorPath()
+ if os.Getenv("UPDATE_VECTORS") == "1" {
+ data, _ := json.MarshalIndent(got, "", " ")
+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Log("registry-vector.json written")
+ return
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read vector (run UPDATE_VECTORS=1 first): %v", err)
+ }
+ var want registryVector
+ if err := json.Unmarshal(raw, &want); err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Fatalf("registry crypto drifted from committed vector\n got %+v\nwant %+v", got, want)
+ }
+ // Belt-and-suspenders: the committed blob opens back to the record under the
+ // committed key + machine_id.
+ wantBlob, _ := hex.DecodeString(want.Blob)
+ wantKey, _ := hex.DecodeString(want.Key)
+ opened, err := OpenRecord(wantKey, wantBlob, want.MachineID)
+ if err != nil {
+ t.Fatalf("committed blob failed to open: %v", err)
+ }
+ if string(opened) != want.Record {
+ t.Fatalf("committed blob opened to %q, want %q", opened, want.Record)
+ }
+}
diff --git a/go/internal/signal/protocol.go b/go/internal/signal/protocol.go
index 9dfba43..1cf8037 100644
--- a/go/internal/signal/protocol.go
+++ b/go/internal/signal/protocol.go
@@ -11,17 +11,20 @@ const (
TypeAnswer = "answer" // agent -> server -> browser
TypeError = "error" // server -> peer: e.g. machine offline
TypeClose = "close" // either way: session ended
+
+ TypeRegistry = "registry" // agent -> relay: publish my (opaque) device registry blob
)
// SignalMsg is the only thing that crosses the signaling WSS. It never contains
// terminal data — only WebRTC SDP and routing. Session is set on agent-facing
// messages so one agent connection can serve multiple browser sessions.
type SignalMsg struct {
- Type string `json:"type"`
- Session string `json:"session,omitempty"`
- SDP string `json:"sdp,omitempty"`
- Reason string `json:"reason,omitempty"`
- Binding string `json:"binding,omitempty"` // opaque wallet-binding record; relay forwards, never reads
+ Type string `json:"type"`
+ Session string `json:"session,omitempty"`
+ SDP string `json:"sdp,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Binding string `json:"binding,omitempty"` // opaque wallet-binding record; relay forwards, never reads
+ Registry string `json:"registry,omitempty"` // opaque encrypted device record; relay holds + serves, never reads
}
func (m SignalMsg) encode() ([]byte, error) { return json.Marshal(m) }
diff --git a/go/internal/signal/registry_e2e_test.go b/go/internal/signal/registry_e2e_test.go
new file mode 100644
index 0000000..174f52b
--- /dev/null
+++ b/go/internal/signal/registry_e2e_test.go
@@ -0,0 +1,102 @@
+package signal
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/srcful/terminal-relay/go/internal/identity"
+)
+
+// TestRegistryE2ESealedRecordRoundTrips ties the registry contract together over a
+// real relay: an agent seals a real record (B2.0) + base64s it onto its live
+// registration (B2.2 wire), the relay serves it verbatim (B2.1, blind), and the
+// fetcher base64-decodes + OpenRecords it with machine_id as AAD (B2.3 wire) to
+// recover the record. This catches cross-component contract drift (base64 form,
+// JSON field names, the AAD) that the per-slice fixtures cannot.
+func TestRegistryE2ESealedRecordRoundTrips(t *testing.T) {
+ srv := httptest.NewServer(New().Handler())
+ defer srv.Close()
+
+ secret := bytes.Repeat([]byte{0x07}, 32)
+ key, err := identity.RegistryKey(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := identity.DeriveWallet(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const machineID = "m-laptop-1"
+ rec := map[string]any{
+ "v": 1, "name": "laptop", "host_pub": strings.Repeat("ab", 32),
+ "signal_url": "https://relay.example", "ts": 1749600000,
+ }
+ pt, _ := json.Marshal(rec)
+ nonce := bytes.Repeat([]byte{0x01}, 12)
+ blob, err := identity.SealRecord(key, nonce, pt, machineID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b64blob := base64.StdEncoding.EncodeToString(blob)
+
+ a := registerAgentWithRegistry(t, srv.URL, wallet.Address, machineID, b64blob)
+ defer a.CloseNow()
+ // A second agent under the SAME wallet publishing a FORGED blob (sealed under a
+ // different key) must be served by the (blind) relay but dropped by the fetcher.
+ forgedKey, _ := identity.RegistryKey(bytes.Repeat([]byte{0xff}, 32))
+ forged, _ := identity.SealRecord(forgedKey, nonce, pt, "m-forged")
+ f := registerAgentWithRegistry(t, srv.URL, wallet.Address, "m-forged", base64.StdEncoding.EncodeToString(forged))
+ defer f.CloseNow()
+
+ // Poll until both blobs are live on the relay.
+ var entries []registryEntry
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ entries = getRegistry(t, srv.URL, wallet.Address, http.StatusOK)
+ if len(entries) == 2 {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("relay served %d entries, want 2", len(entries))
+ }
+
+ // Fetch + decode exactly as the client does: base64-decode, OpenRecord with the
+ // machine_id as AAD. The real record opens; the forgery is dropped.
+ opened := 0
+ for _, e := range entries {
+ raw, err := base64.StdEncoding.DecodeString(e.Blob)
+ if err != nil {
+ t.Fatalf("relay blob is not base64: %v", err)
+ }
+ decPt, err := identity.OpenRecord(key, raw, e.MachineID)
+ if err != nil {
+ continue // forgery / wrong wallet — dropped, exactly like client.FetchRegistry
+ }
+ opened++
+ var got map[string]any
+ if err := json.Unmarshal(decPt, &got); err != nil {
+ t.Fatalf("opened record is not JSON: %v", err)
+ }
+ if e.MachineID != machineID || got["name"] != "laptop" || got["host_pub"] != strings.Repeat("ab", 32) {
+ t.Fatalf("opened record mismatch: id=%s rec=%v", e.MachineID, got)
+ }
+ }
+ if opened != 1 {
+ t.Fatalf("opened %d records, want exactly 1 (the forgery must be dropped)", opened)
+ }
+
+ // Sanity: the relay never persisted anything — a fresh Server has no registry.
+ fresh := httptest.NewServer(New().Handler())
+ defer fresh.Close()
+ if got := getRegistry(t, fresh.URL, wallet.Address, http.StatusOK); len(got) != 0 {
+ t.Fatalf("a fresh relay must serve an empty registry, got %+v", got)
+ }
+}
diff --git a/go/internal/signal/server.go b/go/internal/signal/server.go
index 0e1f7d3..da034b4 100644
--- a/go/internal/signal/server.go
+++ b/go/internal/signal/server.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
+ "encoding/json"
"errors"
"net"
"net/http"
@@ -106,6 +107,24 @@ type agentConn struct {
mu sync.Mutex
sessions map[string]*browserConn // session id -> bound browser
+ registry string // opaque encrypted device blob, published by the agent on this live registration
+}
+
+// setRegistry records the agent's opaque device blob on this live connection.
+// The blob is in-memory soft-state: it rides the registration and is dropped
+// when the agentConn is torn down (no persistence). The relay never reads it.
+func (ac *agentConn) setRegistry(blob string) {
+ ac.mu.Lock()
+ ac.registry = blob
+ ac.mu.Unlock()
+}
+
+// registryBlob returns the published device blob (or "" if none yet), copied out
+// under the lock so callers never read ac.registry without synchronization.
+func (ac *agentConn) registryBlob() string {
+ ac.mu.Lock()
+ defer ac.mu.Unlock()
+ return ac.registry
}
func newAgentConn() *agentConn {
@@ -263,10 +282,45 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/attach", s.handleAttach)
mux.HandleFunc("/pair", s.handlePair)
mux.HandleFunc("/turn-credentials", s.handleTURN)
+ mux.HandleFunc("/registry", s.handleRegistry)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
return mux
}
+// handleRegistry serves GET /registry?wallet=W -> [{machine_id, blob}] for the
+// agents currently registered under wallet W. It is a blind, stateless,
+// unauthenticated pass-through: it lists the opaque blobs published on the live
+// agentConns and never decrypts, verifies, or persists them. A fresh Server has
+// nothing to serve; a relay restart loses every blob and agents re-publish on
+// reconnect. The blobs self-authenticate via their wallet-derived AEAD, so the
+// relay needs no auth here — only a wallet-holder can produce a blob that opens.
+func (s *Server) handleRegistry(w http.ResponseWriter, r *http.Request) {
+ wallet := r.URL.Query().Get("wallet")
+ if wallet == "" {
+ http.Error(w, "missing wallet", http.StatusBadRequest)
+ return
+ }
+ prefix := wallet + "|"
+ type entry struct {
+ MachineID string `json:"machine_id"`
+ Blob string `json:"blob"`
+ }
+ out := []entry{} // [] (not null) when there are no live agents under W
+ s.mu.Lock()
+ for k, ac := range s.agents {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ // Copy the blob string out; do not hold ac's mutex across the s.mu loop.
+ if blob := ac.registryBlob(); blob != "" {
+ out = append(out, entry{MachineID: strings.TrimPrefix(k, prefix), Blob: blob})
+ }
+ }
+ s.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(out) // [] when none; encode never fails the relay
+}
+
func key(owner, machine string) string { return owner + "|" + machine }
func newSessionID() string {
@@ -389,6 +443,13 @@ func (s *Server) handleAgent(w http.ResponseWriter, r *http.Request) {
if err != nil {
continue
}
+ if m.Type == TypeRegistry {
+ // The agent publishes its opaque encrypted device blob on the
+ // live registration. The relay holds it in-memory (no persist,
+ // no decrypt) and serves it via GET /registry.
+ ac.setRegistry(m.Registry)
+ continue
+ }
if m.Type == TypeAnswer {
// Route via THIS agent's own bound sessions, not a global map,
// so an answer can only ever reach a session the agent owns.
diff --git a/go/internal/signal/server_test.go b/go/internal/signal/server_test.go
index ef0c143..feef2ac 100644
--- a/go/internal/signal/server_test.go
+++ b/go/internal/signal/server_test.go
@@ -3,7 +3,11 @@ package signal
import (
"context"
+ "encoding/json"
+ "io"
+ "net/http"
"net/http/httptest"
+ "sort"
"strings"
"testing"
"time"
@@ -199,3 +203,148 @@ func TestAttachCapacityPerAgentFailsFast(t *testing.T) {
}
assertNoSignal(t, agent, 250*time.Millisecond)
}
+
+// registryEntry mirrors the JSON shape returned by GET /registry.
+type registryEntry struct {
+ MachineID string `json:"machine_id"`
+ Blob string `json:"blob"`
+}
+
+// getRegistry fetches GET /registry?wallet=W against the httptest base URL and
+// decodes the response into entries, asserting the status code.
+func getRegistry(t *testing.T, base, wallet string, wantStatus int) []registryEntry {
+ t.Helper()
+ u := base + "/registry"
+ if wallet != "" {
+ u += "?wallet=" + wallet
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET %s: %v", u, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != wantStatus {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("GET %s: status %d want %d (body %q)", u, resp.StatusCode, wantStatus, string(body))
+ }
+ if wantStatus != http.StatusOK {
+ return nil
+ }
+ var out []registryEntry
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode registry: %v", err)
+ }
+ return out
+}
+
+// registerAgentWithRegistry dials /agent/signal for owner|machine, waits for the
+// ready frame, then publishes a TypeRegistry blob as its first message — exactly
+// how a real agent rides its encrypted record on the live registration.
+func registerAgentWithRegistry(t *testing.T, base, owner, machine, blob string) *websocket.Conn {
+ t.Helper()
+ c := dialJSON(t, wsURL(base, "/agent/signal", map[string]string{"owner_id": owner, "machine_id": machine}))
+ if ready := readMsg(t, c); ready.Type != TypeReady {
+ t.Fatalf("expected ready, got %q", ready.Type)
+ }
+ writeMsg(t, c, SignalMsg{Type: TypeRegistry, Registry: blob})
+ return c
+}
+
+func sortEntries(e []registryEntry) {
+ sort.Slice(e, func(i, j int) bool { return e[i].MachineID < e[j].MachineID })
+}
+
+func TestRegistryListsLiveAgents(t *testing.T) {
+ srv := httptest.NewServer(New().Handler())
+ defer srv.Close()
+
+ const W = "wallet-W"
+ const W2 = "wallet-W2"
+
+ // Two live agents under W, each publishing its own opaque blob.
+ a1 := registerAgentWithRegistry(t, srv.URL, W, "m1", "blob1")
+ defer a1.CloseNow()
+ a2 := registerAgentWithRegistry(t, srv.URL, W, "m2", "blob2")
+ defer a2.CloseNow()
+ // An agent under a different wallet must never be listed for W.
+ other := registerAgentWithRegistry(t, srv.URL, W2, "m3", "blob3")
+ defer other.CloseNow()
+
+ // The blob publish rides the live connection asynchronously; poll briefly so
+ // the read loop has stored both before we assert.
+ var got []registryEntry
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ got = getRegistry(t, srv.URL, W, http.StatusOK)
+ if len(got) == 2 {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ sortEntries(got)
+ want := []registryEntry{{MachineID: "m1", Blob: "blob1"}, {MachineID: "m2", Blob: "blob2"}}
+ if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ t.Fatalf("registry for W = %+v, want %+v", got, want)
+ }
+
+ // W2's agent is isolated to W2 — never leaks into W's list.
+ w2 := getRegistry(t, srv.URL, W2, http.StatusOK)
+ if len(w2) != 1 || w2[0].MachineID != "m3" || w2[0].Blob != "blob3" {
+ t.Fatalf("registry for W2 = %+v, want one m3/blob3 entry", w2)
+ }
+
+ // Disconnect m1 — it must drop from the list (in-memory soft-state, no
+ // persistence). The blob lives only on the live agentConn.
+ a1.Close(websocket.StatusNormalClosure, "")
+ deadline = time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ got = getRegistry(t, srv.URL, W, http.StatusOK)
+ if len(got) == 1 {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if len(got) != 1 || got[0].MachineID != "m2" || got[0].Blob != "blob2" {
+ t.Fatalf("after m1 disconnect, registry for W = %+v, want only m2/blob2", got)
+ }
+}
+
+func TestRegistryUnknownWalletIsEmptyArray(t *testing.T) {
+ srv := httptest.NewServer(New().Handler())
+ defer srv.Close()
+
+ // A fresh Server holds no registry state: an unknown wallet returns [] (200),
+ // never an error.
+ u := srv.URL + "/registry?wallet=Unknown"
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status %d want 200", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.TrimSpace(string(body)); got != "[]" {
+ t.Fatalf("unknown wallet body = %q, want []", got)
+ }
+}
+
+func TestRegistryMissingWalletIsBadRequest(t *testing.T) {
+ srv := httptest.NewServer(New().Handler())
+ defer srv.Close()
+
+ getRegistry(t, srv.URL, "", http.StatusBadRequest)
+}
diff --git a/testdata/registry-vector.json b/testdata/registry-vector.json
new file mode 100644
index 0000000..2f8b9e7
--- /dev/null
+++ b/testdata/registry-vector.json
@@ -0,0 +1,8 @@
+{
+ "secret": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
+ "key": "4c4622be2709fc210fc800b116fb4beaa1db481e1b67ef0047fd3f096e098bf8",
+ "nonce": "0102030405060708090a0b0c",
+ "record": "{\"v\":1,\"name\":\"zap-kitchen\",\"host_pub\":\"269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613\",\"signal_url\":\"wss://signal.miranda.example/agent\",\"ts\":1749600000}",
+ "machine_id": "a1b2c3d4e5f60718",
+ "blob": "0102030405060708090a0b0c09dca3867a72fecb925a9bf82e67c8c41e7d8c0f0a2fabc49eb4576982e17578fb79bba17111a6e4663a89af5e680a1a0cce8a0780ffd5151f73e94d8fd97614b5702532b7b6551e998b4d0b1aeaca8ec046ff22be1643644aa7faa95b7cfe85604eeb4126f9797e3ad6c105682835afa01ddf258b923fa737d8384411014167800d2452d77e19dbbd36114894665422729a60525dd83718d47bde60fa5ae553497eb9ec33e62e98d11261d8d82e00f59b29a721e3bd8da84f8aef0a"
+}
diff --git a/web/src/app.js b/web/src/app.js
index 248693a..f3abed3 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -9,6 +9,7 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { listMachines, addMachine } from './store.js';
+import { fetchMachines, mergeMachines, freshDevices } from './registry.js';
import { pairWithCode } from './pair.js';
import { confirmPairingSafety, machineAfterConfirmedPairing, pendingPairingConfirmation } from './pairing/confirm.js';
import { registerPasskey, signInPasskey, devOwnerKey, passkeySupported, isLocalhost } from './identity.js';
@@ -281,19 +282,51 @@ const el = (tag, props = {}, ...kids) => {
function mount(root, node) { root.replaceChildren(node); }
-function viewMachines(root) {
+// newDevices returns the discovered machines not seen before (for a notice) and
+// persists the seen set in localStorage, so the notice fires once per device.
+function newDevices(discovered) {
+ let seen = [];
+ try { seen = JSON.parse(localStorage.getItem('tr_seen') || '[]'); } catch {}
+ const fresh = freshDevices(seen, discovered);
+ if (fresh.length) {
+ localStorage.setItem('tr_seen', JSON.stringify([...new Set([...seen, ...fresh.map((m) => m.machine_id)])]));
+ }
+ return fresh;
+}
+
+function renderMachines(root, machines, fresh) {
const grid = el('div', { className: 'grid' });
- for (const m of listMachines()) {
+ for (const m of machines) {
grid.append(el('button', { className: 'card machine', onclick: () => viewTerminal(root, m) },
el('div', { className: 'name' }, m.name || m.machine_id),
el('div', { className: 'sub' }, m.machine_id.slice(0, 12) + '…')));
}
grid.append(el('button', { className: 'card add', onclick: () => viewPair(root) },
el('div', { className: 'plus' }, '+'), el('div', { className: 'sub' }, 'Pair a machine')));
- mount(root, el('div', { className: 'view' },
+ const kids = [
el('h1', {}, 'your machines'),
el('p', { className: 'muted' }, 'Reach a shell on any of them — peer-to-peer, end-to-end encrypted.'),
- grid));
+ ];
+ if (fresh && fresh.length) {
+ kids.push(el('p', { className: 'muted' }, '📣 new device joined: ' + fresh.map((m) => m.name || m.machine_id).join(', ')));
+ }
+ kids.push(grid);
+ mount(root, el('div', { className: 'view' }, ...kids));
+}
+
+// viewMachines renders the locally-stored machines immediately, then enriches the
+// list from the wallet's encrypted registry (B2) — your machines appear by name with
+// no manual pairing. The fetch is same-origin (the relay that served this app) and
+// best-effort: a failure just leaves the local list. Discovery only.
+function viewMachines(root) {
+ renderMachines(root, listMachines(), []);
+ (async () => {
+ try {
+ const discovered = await fetchMachines(location.origin, walletKey(), _id.secret);
+ if (!discovered.length) return;
+ renderMachines(root, mergeMachines(listMachines(), discovered), newDevices(discovered));
+ } catch { /* not signed in / relay unreachable — keep the local list */ }
+ })();
}
// codeFromScan extracts the pairing code from a scanned QR, which encodes
diff --git a/web/src/identity.js b/web/src/identity.js
index 3763705..f226dec 100644
--- a/web/src/identity.js
+++ b/web/src/identity.js
@@ -11,11 +11,13 @@ import { resolveRPID } from './rp.js';
import { randomBytes } from '@noble/hashes/utils';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
-// identityFromPRF derives the full identity ({ owner, wallet }) rooted in one
-// 32-byte secret — the passkey prf output, or the dev secret. Mirrors how the Go
-// owner.json roots both keys in a single seed.
+// identityFromPRF derives the full identity ({ owner, wallet, secret }) rooted in
+// one 32-byte secret — the passkey prf output, or the dev secret. Mirrors how the
+// Go owner.json roots both keys in a single seed. `secret` is kept IN MEMORY only
+// (never persisted) so the session can derive the registry key (B2); it is the same
+// secret deriveWallet/deriveOwnerKey consume.
function identityFromPRF(secret) {
- return { owner: deriveOwnerKey(secret), wallet: deriveWallet(secret) };
+ return { owner: deriveOwnerKey(secret), wallet: deriveWallet(secret), secret };
}
const enc = new TextEncoder();
diff --git a/web/src/identity/registry.js b/web/src/identity/registry.js
new file mode 100644
index 0000000..7cb858f
--- /dev/null
+++ b/web/src/identity/registry.js
@@ -0,0 +1,34 @@
+// web/src/identity/registry.js
+// Mirrors go/internal/identity/registry.go: a wallet-derived symmetric key and a
+// ChaCha20-Poly1305 (IETF, 12-byte nonce) record seal/open. Byte-identical to Go,
+// gated by testdata/registry-vector.json. Only wallet-holders can derive K_reg;
+// the relay only ever holds the opaque nonce||ciphertext||tag blob.
+import { chacha20poly1305 } from '@noble/ciphers/chacha';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+
+const enc = new TextEncoder();
+const SALT = enc.encode('miranda/registry/v1');
+const INFO = enc.encode('aead-key');
+
+// registryKey derives the 32-byte registry AEAD key from the wallet's 32-byte prf
+// secret. K_reg = HKDF-SHA256(secret, salt='miranda/registry/v1', info='aead-key').
+export function registryKey(secret) {
+ return hkdf(sha256, secret, SALT, INFO, 32);
+}
+
+// sealRecord encrypts plaintext under key with machineID as AEAD associated data,
+// returning nonce||ciphertext||tag. nonce must be 12 bytes.
+export function sealRecord(key, nonce, plaintext, machineID) {
+ const ct = chacha20poly1305(key, nonce, enc.encode(machineID)).encrypt(plaintext);
+ const out = new Uint8Array(nonce.length + ct.length);
+ out.set(nonce);
+ out.set(ct, nonce.length);
+ return out;
+}
+
+// openRecord reverses sealRecord. Throws on any failure — a forged/garbage blob,
+// or a wrong machineID (AAD), fails here (never returns partial plaintext).
+export function openRecord(key, blob, machineID) {
+ return chacha20poly1305(key, blob.slice(0, 12), enc.encode(machineID)).decrypt(blob.slice(12));
+}
diff --git a/web/src/registry.js b/web/src/registry.js
new file mode 100644
index 0000000..95ecd2c
--- /dev/null
+++ b/web/src/registry.js
@@ -0,0 +1,66 @@
+// web/src/registry.js — discover your machines from the relay's encrypted device
+// registry (B2). Mirrors go/internal/client/registry.go. The relay serves opaque
+// blobs keyed by wallet; only a wallet-holder (registryKey) can open them, so a
+// forged/garbage blob fails to open and is silently dropped. Discovery only — the
+// Noise data plane and attach path are unchanged.
+import { registryKey, openRecord } from './identity/registry.js';
+
+const td = new TextDecoder();
+
+function b64ToBytes(s) {
+ const bin = atob(s);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+// decodeRegistry turns the relay's `[{machine_id, blob}]` into machines, dropping
+// any blob that fails to open (a forgery, or one sealed under a different wallet).
+// fallbackSignal is used when a record carries no signal_url of its own.
+export function decodeRegistry(entries, secret, fallbackSignal) {
+ const key = registryKey(secret);
+ const out = [];
+ for (const e of entries || []) {
+ let rec;
+ try {
+ rec = JSON.parse(td.decode(openRecord(key, b64ToBytes(e.blob), e.machine_id)));
+ } catch {
+ continue; // forged / garbage / wrong machine_id — drop it
+ }
+ out.push({
+ machine_id: e.machine_id,
+ name: rec.name,
+ host_pub: rec.host_pub,
+ signal: rec.signal_url || fallbackSignal,
+ });
+ }
+ return out;
+}
+
+// fetchMachines GETs the wallet's registry from `origin` and decodes it. Best-effort:
+// any failure (relay down, not served same-origin, bad JSON) returns [] so the caller
+// falls back to the locally-stored machine list without surfacing noise.
+export async function fetchMachines(origin, wallet, secret) {
+ try {
+ const url = origin.replace(/\/$/, '') + '/registry?wallet=' + encodeURIComponent(wallet.address);
+ const r = await fetch(url);
+ if (!r.ok) return [];
+ return decodeRegistry(await r.json(), secret, origin);
+ } catch {
+ return [];
+ }
+}
+
+// mergeMachines unions local and discovered machines by machine_id; a machine the
+// user already stored locally wins, discovered-only machines are appended.
+export function mergeMachines(local, discovered) {
+ const seen = new Set(local.map((m) => m.machine_id));
+ return local.concat(discovered.filter((m) => !seen.has(m.machine_id)));
+}
+
+// freshDevices returns the discovered machines whose machine_id is not in seenIds
+// (for a one-time "new device joined" notice). Pure — the caller owns the seen set.
+export function freshDevices(seenIds, discovered) {
+ const seen = new Set(seenIds);
+ return discovered.filter((m) => m.machine_id && !seen.has(m.machine_id));
+}
diff --git a/web/sw.js b/web/sw.js
index 3a4219e..40c4845 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -30,6 +30,7 @@ const SHELL = [
'/src/identity/auth.js',
'/src/identity/binding.js',
'/src/identity/owner.js',
+ '/src/identity/registry.js',
'/src/identity/wallet.js',
'/src/net/backoff.js',
'/src/net/reconnect.js',
@@ -41,6 +42,7 @@ const SHELL = [
'/src/pairing/confirm.js',
'/src/pairing/nnpsk0.js',
'/src/pairing/sas.js',
+ '/src/registry.js',
'/src/rp.js',
'/src/store.js',
'/src/ui/keybar.js',
diff --git a/web/test/registry-web.test.js b/web/test/registry-web.test.js
new file mode 100644
index 0000000..04a88b6
--- /dev/null
+++ b/web/test/registry-web.test.js
@@ -0,0 +1,50 @@
+// web/test/registry-web.test.js — the browser-side registry discovery (B2):
+// decode the relay's encrypted entries, drop forgeries, merge, and flag new devices.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { hexToBytes } from '@noble/hashes/utils';
+import { registryKey, sealRecord } from '../src/identity/registry.js';
+import { decodeRegistry, mergeMachines, freshDevices } from '../src/registry.js';
+
+const enc = new TextEncoder();
+const b64 = (u8) => Buffer.from(u8).toString('base64');
+
+// entry seals a record under `secret`'s key, AAD = machine_id, as the relay would
+// serve it: { machine_id, blob(base64) }.
+function entry(secret, machineID, rec) {
+ const key = registryKey(secret);
+ const nonce = new Uint8Array(12).fill(7);
+ const blob = sealRecord(key, nonce, enc.encode(JSON.stringify(rec)), machineID);
+ return { machine_id: machineID, blob: b64(blob) };
+}
+
+const SECRET = hexToBytes('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff');
+
+test('decodeRegistry opens your records and drops forgeries', () => {
+ const good = entry(SECRET, 'm1', { v: 1, name: 'laptop', host_pub: 'aa'.repeat(32), signal_url: 'https://relay.example' });
+ const forged = entry(hexToBytes('ff'.repeat(32)), 'm2', { v: 1, name: 'evil', host_pub: 'bb'.repeat(32) }); // wrong key
+ const out = decodeRegistry([good, forged], SECRET, 'https://fallback.example');
+ assert.equal(out.length, 1, 'the forgery must be dropped');
+ assert.deepEqual(out[0], { machine_id: 'm1', name: 'laptop', host_pub: 'aa'.repeat(32), signal: 'https://relay.example' });
+});
+
+test('decodeRegistry falls back to the fetch origin when a record has no signal_url', () => {
+ const e = entry(SECRET, 'm3', { v: 1, name: 'box', host_pub: 'cc'.repeat(32) });
+ const out = decodeRegistry([e], SECRET, 'https://origin.example');
+ assert.equal(out[0].signal, 'https://origin.example');
+});
+
+test('mergeMachines: local wins, discovered-only appended', () => {
+ const local = [{ machine_id: 'm1', name: 'local-laptop' }];
+ const disc = [{ machine_id: 'm1', name: 'reg-laptop' }, { machine_id: 'm2', name: 'desktop' }];
+ const merged = mergeMachines(local, disc);
+ assert.equal(merged.length, 2);
+ assert.equal(merged[0].name, 'local-laptop', 'local entry wins');
+ assert.equal(merged[1].machine_id, 'm2');
+});
+
+test('freshDevices flags only unseen machine_ids', () => {
+ const disc = [{ machine_id: 'm1', name: 'a' }, { machine_id: 'm2', name: 'b' }];
+ assert.deepEqual(freshDevices(['m1'], disc).map((m) => m.machine_id), ['m2']);
+ assert.deepEqual(freshDevices(['m1', 'm2'], disc), []);
+});
diff --git a/web/test/registry.test.js b/web/test/registry.test.js
new file mode 100644
index 0000000..8f5066d
--- /dev/null
+++ b/web/test/registry.test.js
@@ -0,0 +1,52 @@
+// web/test/registry.test.js — asserts the Go-written registry-vector.json vector
+// and round-trip/tamper behaviour, byte-identical to go/internal/identity/registry.go.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
+import { registryKey, sealRecord, openRecord } from '../src/identity/registry.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const td = (f) => JSON.parse(readFileSync(join(here, '..', '..', 'testdata', f), 'utf8'));
+const v = td('registry-vector.json');
+const enc = new TextEncoder();
+
+test('registryKey reproduces the Go vector key (deterministic HKDF)', () => {
+ const key = registryKey(hexToBytes(v.secret));
+ assert.equal(bytesToHex(key), v.key);
+});
+
+test('sealRecord is byte-identical to the Go vector blob', () => {
+ const key = hexToBytes(v.key);
+ const blob = sealRecord(key, hexToBytes(v.nonce), enc.encode(v.record), v.machine_id);
+ assert.equal(bytesToHex(blob), v.blob);
+});
+
+test('openRecord round-trips the committed blob', () => {
+ const key = hexToBytes(v.key);
+ const opened = openRecord(key, hexToBytes(v.blob), v.machine_id);
+ assert.equal(new TextDecoder().decode(opened), v.record);
+});
+
+test('seal then open round-trips a fresh record', () => {
+ const key = registryKey(hexToBytes(v.secret));
+ const nonce = new Uint8Array(12).fill(9);
+ const pt = enc.encode('{"v":1,"name":"zap-garage"}');
+ const blob = sealRecord(key, nonce, pt, 'deadbeefcafe0001');
+ const back = openRecord(key, blob, 'deadbeefcafe0001');
+ assert.deepEqual(back, pt);
+});
+
+test('openRecord throws on a wrong machine_id (AAD)', () => {
+ const key = hexToBytes(v.key);
+ assert.throws(() => openRecord(key, hexToBytes(v.blob), 'b1b2c3d4e5f60718'));
+});
+
+test('openRecord throws on a tampered ciphertext byte', () => {
+ const key = hexToBytes(v.key);
+ const blob = hexToBytes(v.blob);
+ blob[12] ^= 0x01; // flip first ciphertext byte (past the 12-byte nonce)
+ assert.throws(() => openRecord(key, blob, v.machine_id));
+});