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)); +});