From c9a4423f7f70119d47afd2ecfee9a67824848aa0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 14 Jun 2026 10:06:48 +0200 Subject: [PATCH] =?UTF-8?q?docs(spec):=20B2=20wallet=20device=20registry?= =?UTF-8?q?=20=E2=80=94=20design=20+=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stateless, encrypted, blind-relay device registry: your machines appear everywhere by name with no add-machine and no SAS. Decisions: encrypted (relay fully blind), relay stays STATELESS (in-memory soft-state riding the live agent registration, rebuilt on reconnect), zero-touch enrollment + notify. AEAD (ChaCha20-Poly1305, machine_id AAD) gives encryption AND authenticity, so the relay verifies nothing. Discovery-only — B1.4 acceptance + LAN-direct unchanged; pair/add-machine kept for cross-wallet. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-14-b2-device-registry-plan.md | 224 ++++++++++++++++++ .../2026-06-14-b2-device-registry-design.md | 152 ++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-b2-device-registry-plan.md create mode 100644 docs/superpowers/specs/2026-06-14-b2-device-registry-design.md diff --git a/docs/superpowers/plans/2026-06-14-b2-device-registry-plan.md b/docs/superpowers/plans/2026-06-14-b2-device-registry-plan.md new file mode 100644 index 0000000..432c649 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-b2-device-registry-plan.md @@ -0,0 +1,224 @@ +# B2 — Wallet device registry — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. +> Steps use checkbox (`- [ ]`) syntax. + +**Goal:** your machines appear everywhere by name, attachable with no `add-machine` and no +SAS — via a stateless, encrypted, blind-relay device registry. Spec: +`docs/superpowers/specs/2026-06-14-b2-device-registry-design.md`. + +**Architecture:** each `mir up` agent seals an encrypted record `{name, host_pub, signal_url, +ts}` under `K_reg = HKDF(wallet_secret, "miranda/registry/v1")` and attaches it to its live +`/agent/signal` registration. The relay holds it in-memory (no persistence) and serves +`GET /registry?wallet=W → [{machine_id, blob}]`. Clients/browser fetch, AEAD-open (forgeries +self-drop), and auto-list. Discovery only; B1.4 acceptance + LAN-direct unchanged. + +**Tech stack:** Go (`internal/{identity,signal,agent,client,cli}`), JS (`web/src`), +`golang.org/x/crypto/chacha20poly1305`, `@noble/ciphers/chacha`, `testdata/` vectors. + +**Cross-cutting facts:** +- `wallet_secret` = the 32-byte prf root in `owner.json` (`Identity.Secret()`); never sent to + the relay. `K_reg` derives from it. +- AEAD = ChaCha20-Poly1305 (IETF, 12-byte random nonce), `aad = machine_id`. Wire blob = + `nonce || ciphertext||tag`. Seal/open must be byte-identical Go↔JS (vector-gated). +- The agent→relay channel is `signal.SignalMsg` (B1.4 added the opaque `Binding` field; B2 + adds an opaque `Registry` field the same way). +- Relay stays **blind + stateless**: it stores one opaque blob per *live* `agentConn` and + serves a list; it never decrypts/verifies/persists. + +--- + +## Task 1 — B2.0: registry crypto (Go + JS + vectors) + +**Files:** Create `go/internal/identity/registry.go` + `registry_test.go`; +`web/src/identity/registry.js` + `web/test/registry.test.js`; `testdata/registry-vector.json`. + +- [ ] **Step 1 (Go test first).** `TestRegistryKeyAndSeal`: from a fixed 32-byte secret, + `RegistryKey(secret)` is deterministic; `SealRecord(key, nonce, plaintext, machineID)` then + `OpenRecord(key, blob, machineID)` round-trips; a wrong `machineID` (AAD) fails open; a + flipped ciphertext byte fails open. +- [ ] **Step 2: implement `registry.go`.** + ```go + const registrySalt = "miranda/registry/v1" + + // RegistryKey derives the symmetric registry key from the wallet's prf secret. + func RegistryKey(secret []byte) ([]byte, error) { + r := hkdf.New(sha256.New, secret, []byte(registrySalt), []byte("aead-key")) + k := make([]byte, chacha20poly1305.KeySize) + _, err := io.ReadFull(r, k) + return k, err + } + + // SealRecord encrypts plaintext under key with machineID as AAD; returns nonce||ct. + 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: bad nonce") } + ct := aead.Seal(nil, nonce, plaintext, []byte(machineID)) + return append(append([]byte{}, nonce...), ct...), nil + } + + // OpenRecord reverses SealRecord; returns an error (not plaintext) on any failure. + 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)) + } + ``` +- [ ] **Step 3: run** `cd go && go test ./internal/identity/ -run Registry -v` (fail→pass). +- [ ] **Step 4: JS mirror + vendored-cipher check.** Confirm the vendored + `web/vendor/noble-ciphers-chacha.js` exports `chacha20poly1305`; if not, re-bundle via + `npx esbuild node_modules/@noble/ciphers/chacha.js --bundle --format=esm --minify` (as we + did for pbkdf2) and update the importmap/sw.js. Create `web/src/identity/registry.js`: + ```js + import { chacha20poly1305 } from '@noble/ciphers/chacha'; + import { hkdf } from '@noble/hashes/hkdf'; + import { sha256 } from '@noble/hashes/sha2'; + const SALT = new TextEncoder().encode('miranda/registry/v1'); + const INFO = new TextEncoder().encode('aead-key'); + export function registryKey(secret) { return hkdf(sha256, secret, SALT, INFO, 32); } + export function sealRecord(key, nonce, plaintext, machineID) { + const aad = new TextEncoder().encode(machineID); + const ct = chacha20poly1305(key, nonce, aad).encrypt(plaintext); + const out = new Uint8Array(nonce.length + ct.length); out.set(nonce); out.set(ct, nonce.length); + return out; + } + export function openRecord(key, blob, machineID) { + const aad = new TextEncoder().encode(machineID); + return chacha20poly1305(key, blob.slice(0, 12), aad).decrypt(blob.slice(12)); // throws on failure + } + ``` +- [ ] **Step 5: vector.** `testdata/registry-vector.json`: fixed `secret`, derived `key` + (hex), fixed `nonce` (hex), a fixed `record` JSON, `machine_id`, and `blob` (hex). Generate + with `UPDATE_VECTORS=1 go test ./internal/identity/ -run RegistryVector`; assert the JS test + reproduces `key` and `blob` and that `openRecord` round-trips. Cross-check the AEAD output + once against an independent oracle (`uv run --with cryptography`) so the gate rests on more + than Go↔JS self-agreement. +- [ ] **Step 6:** `cd go && go test ./...` and `cd web && npm test` green. Commit: + `feat(identity): B2.0 registry key + ChaCha20-Poly1305 record seal/open (Go+JS+vector)`. + +--- + +## Task 2 — B2.1: relay registry (blind, stateless) + +**Files:** Modify `go/internal/signal/protocol.go` (add `Registry` field), `signal/server.go` +(store blob on agentConn + `GET /registry`), `go/cmd/mir-signal` route. Test: +`signal/server_test.go`. + +- [ ] **Step 1: test first.** `TestRegistryListsLiveAgents`: register two fake agents under the + same `owner_id=W` (different machine_ids), each sending a first `SignalMsg{Type:"registry", + Registry: }`; `GET /registry?wallet=W` returns both `{machine_id, blob}` entries; a + disconnected agent drops out; an agent under a different wallet is not listed. +- [ ] **Step 2: protocol.** Add `Registry string `json:"registry,omitempty"`` to `SignalMsg` + and a `TypeRegistry = "registry"` const. +- [ ] **Step 3: store the blob.** In `handleAgent`'s read loop, on `Type == TypeRegistry` + record `ac.registry = m.Registry` (add a `registry string` field to `agentConn`, guarded by + its mutex). It rides the existing live connection — dropped automatically when `ac` is torn + down. (Optional small grace: keep serving the last blob for `registryGrace` after + disconnect; default 0 to start — reconnect re-publishes fast.) +- [ ] **Step 4: GET handler.** `handleRegistry(w, r)`: read `wallet=`; scan `s.agents` for keys + with prefix `wallet+"|"`; for each live `ac` with a non-empty `ac.registry`, append + `{"machine_id": , "blob": }`; write JSON. No auth, no decrypt (blobs are + opaque + self-authenticating). Register `mux.HandleFunc("/registry", s.handleRegistry)`. +- [ ] **Step 5:** `cd go && go test ./internal/signal/`; `go vet`, `gofmt`. Commit: + `feat(signal): B2.1 stateless blind device registry (blob on live reg + GET /registry)`. + +--- + +## Task 3 — B2.2: agent publishes + auto-serves its own wallet + +**Files:** Modify `go/internal/agent/runtime.go` (publish blob on connect), `go/internal/cli/ +agent_cmds.go` (`cmdUp` loads the wallet + auto-pin), maybe `agent/store.go`. Test: +`agent/*_test.go`. + +- [ ] **Step 1: cmdUp loads the wallet + auto-pins.** In `cmdUp`, after `agent.LoadOrInit`, + load the client identity (`a.identity(*dir)`); if it `HasWallet()`, `agent.PinOwner(*dir, + id.WalletAddress)` (idempotent) so the machine serves its own wallet with no pairing, and + pass the wallet secret into the Runtime (e.g. `rt.SetWallet(secret)`), so it can build the + registry record. A wallet-less (legacy) `mir up` keeps today's behavior (serve PairedOwners, + no registry publish). +- [ ] **Step 2: build + publish the record.** Add to `Runtime` a method that, on each healthy + registration for `owner == self_wallet`, builds `record = {v:1, name, host_pub, signal_url, + ts}`, `blob = identity.SealRecord(RegistryKey(secret), rand12, json(record), machineID)`, + and sends `SignalMsg{Type: signal.TypeRegistry, Registry: base64(blob)}` as the **first** + message after the relay accepts the registration (in `serveOnce`, right after connect). +- [ ] **Step 3: test.** `TestAgentPublishesRegistryRecord`: a fake relay captures the first + message on the agent connection; assert it's a `TypeRegistry` whose blob `OpenRecord`s (with + the test wallet's `K_reg`) to a record carrying the machine's name + host_pub. Verify + `cmdUp` auto-pins the wallet (`IsOwnerPinned(wallet)` true after). +- [ ] **Step 4:** `cd go && go test ./internal/agent/ ./internal/cli/`; vet/gofmt. Commit: + `feat(agent): B2.2 mir up auto-serves own wallet + publishes encrypted registry record`. + +--- + +## Task 4 — B2.3: client discovery (fetch, merge, notify) + +**Files:** Create `go/internal/client/registry.go` (+ test); modify `cli/client_cmds.go` +(`cmdList`, `cmdAttach`), maybe `client/store.go` (seen-set for notify). + +- [ ] **Step 1: registry fetch.** `client.FetchRegistry(ctx, signalURL string, id *Identity) + ([]Machine, error)`: `GET /registry?wallet=`; for each entry, + `OpenRecord(RegistryKey(id.Secret()), blob, machine_id)` (drop on error); JSON-parse the + record → `Machine{Name, MachineID: machine_id, HostPubHex: rec.host_pub, SignalURL: + rec.signal_url}`. Returns the discovered machines. +- [ ] **Step 2: merge + notify helper.** `mergeMachines(local, discovered)` (discovered fills + in machines not in local; local wins on name conflicts). `notifyNewDevices(a.errOut, dir, + discovered)`: load a `seen.json` set of machine_ids, print `📣 new device "" joined + your wallet` for each unseen, persist the updated set. Tests: merge precedence; notify fires + once per new machine_id. +- [ ] **Step 3: wire `mir list`.** `cmdList` merges `ListMachines(dir)` with + `FetchRegistry(...)` (best-effort — a relay error or no wallet falls back to local only) and + prints the union, marking discovered ones. Notify on new. +- [ ] **Step 4: wire `mir attach `.** Resolve the name from local machines first, then + the registry; on a registry hit, attach using the wallet-authenticated `host_pub` (no + add-machine needed). Best-effort registry; LAN-direct/relay attach paths unchanged. +- [ ] **Step 5:** `cd go && go test ./internal/client/ ./internal/cli/`; vet/gofmt. Commit: + `feat(client): B2.3 registry discovery — list/attach auto-find your machines + notify`. + +--- + +## Task 5 — B2.4: browser auto-list + +**Files:** Create `web/src/identity/registry.js` (done in B2.0) usage in `web/src/app.js` +(+ `web/src/store.js`); `web/sw.js` (precache registry.js). Test: `web/test/*`. + +- [ ] **Step 1:** add a `fetchRegistry(signalURL, wallet)` in `web/src/store.js` (or app.js): + `fetch(signalURL + '/registry?wallet=' + wallet.address)`, `openRecord(registryKey(prf), …)` + each blob (drop failures), return machines. +- [ ] **Step 2:** the machine-list view merges stored machines with the fetched registry, shows + names, and surfaces a "new device joined" notice (mirror the CLI's seen-set in localStorage). +- [ ] **Step 3:** `web/sw.js` precache `/src/identity/registry.js` (sorted); the importmap test + (added in the pbkdf2 fix) keeps `@noble/ciphers/chacha` honest. +- [ ] **Step 4:** `cd web && npm test`. Commit: + `feat(web): B2.4 auto-list your machines from the encrypted registry + notify`. + +--- + +## Task 6 — B2.5: e2e + deploy + +- [ ] **Step 1: e2e (no manual add-machine).** In-process relay + a `mir up` (wallet) that + publishes + a client that `FetchRegistry` discovers it and attaches by name — asserting a + shell round-trips with **no `add-machine`/`pair`**. Negative: a forged blob (sealed under a + different key) is silently dropped by the client. +- [ ] **Step 2: relay still blind + stateless.** A test asserting `GET /registry` returns only + opaque blobs and that nothing is persisted across a fresh `Server` (in-memory only). +- [ ] **Step 3: gates.** `cd go && go test ./... && go vet ./... && gofmt -l .` clean; + `cd web && npm test`; registry vector stable; Noise/wallet/binding vectors untouched. +- [ ] **Step 4: docs.** README ("your machines appear automatically") + SECURITY.md (registry + is encrypted/blind/stateless; revocation = power off / rotate). +- [ ] **Step 5: deploy.** **Fredrik's hand:** redeploy `mir-signal` (adds `/registry` + + the `Registry` passthrough — additive, backward-compatible). Health-gated. +- [ ] **Step 6: commit** `test(registry): B2.5 e2e discovery + blind/stateless relay guards`. + +--- + +## Self-review notes +- **Spec coverage:** Tasks 1–6 cover B2.0 (crypto) → B2.5 (e2e+deploy). Relay stays + blind+stateless (blob on live reg, no persistence, no verify). Discovery-only (LAN-direct + + B1.4 acceptance untouched). `pair`/`add-machine` kept. +- **Type consistency:** `RegistryKey`/`SealRecord`/`OpenRecord`, `SignalMsg.Registry` + + `TypeRegistry`, `agentConn.registry`, `FetchRegistry`, the `{v,name,host_pub,signal_url,ts}` + record, and the `machine_id` AAD are referenced consistently across tasks and Go↔JS. +- **No new persistence:** the registry is in-memory soft-state on `agentConn`; a relay restart + loses it and the agents rebuild it — verified in B2.5 Step 2. +- **Deploy is additive:** old clients ignore `/registry`; old agents simply publish nothing. diff --git a/docs/superpowers/specs/2026-06-14-b2-device-registry-design.md b/docs/superpowers/specs/2026-06-14-b2-device-registry-design.md new file mode 100644 index 0000000..bebfb7f --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-b2-device-registry-design.md @@ -0,0 +1,152 @@ +# B2 — Wallet-signed device registry (stateless, encrypted, blind relay) + +**Status:** Approved design (2026-06-14). Implements **B2** of the north-star +(`2026-06-10-north-star-mesh-wallet-identity-design.md`). Builds on B1 (wallet identity) + +B1.4 (wallet-signed bindings). The payoff of wallet-rooted identity: *your machines appear +everywhere, by name, with no manual `add-machine` and no SAS between your own devices.* + +**Decisions (review, 2026-06-14):** +1. **Encrypted, relay fully blind.** Device records are AEAD-encrypted under a wallet-derived + key; the relay only ever holds/serves opaque blobs. +2. **Relay stays STATELESS.** No persistent store, no database. The registry is **in-memory + soft-state** that rides along with the existing live agent registrations and is rebuilt by + the devices themselves on reconnect — exactly like today's live registrations. +3. **Zero-touch enrollment + notify.** A device that has the wallet self-publishes and works + immediately; other devices print a one-line "new device joined" notice on first sight. + +**Goal:** add a device = have the wallet (passkey-sync or `mir wallet import-phrase`) + start +serving → it appears, by name, on all your devices, attachable with no pairing. Discovery +only; the Noise data plane and B1.4 acceptance are unchanged. + +--- + +## Where we are / the friction this kills + +- B1 gave every device a wallet; B1.4 lets an agent accept *any* X25519 transport key the + wallet signs a binding for. But discovery is still manual: `mir add-machine` / `mir pair` + per machine, and **re-pairing every machine after `mir keygen --wallet`** (the friction + Fredrik just hit). +- The relay already keeps **in-memory soft-state**: live agent registrations, pairing rooms, + browser sessions — all ephemeral, all rebuilt when participants reconnect. The registry is + the same shape: it does **not** need to be a database. + +--- + +## The design + +### Registry = soft-state on the live agent registration +When `mir up` connects to `/agent/signal?owner_id=&machine_id=…` it sends, as the +first message, its **encrypted device record**. The relay holds that blob **in-memory, tied +to the live registration** (a short grace period absorbs a reconnect blink), and drops it +when the agent disconnects. A relay restart loses everything; agents reconnect (existing +backoff) and re-publish within seconds. **No disk, no DB, no write-auth endpoint.** + +``` +GET /registry?wallet= -> [ { machine_id, blob(base64) }, … ] # live regs under W +``` +The relay returns the opaque blobs of every currently-registered agent under that wallet. It +verifies nothing and decrypts nothing — a dumb, blind pass-through. + +### The record: AEAD = encryption AND authenticity, for free +``` +K_reg = HKDF-SHA256(ikm = wallet_secret, salt = "miranda/registry/v1", info = "aead-key") +record = { v:1, name, host_pub, signal_url, ts } # JSON plaintext +blob = ChaCha20-Poly1305_seal(K_reg, nonce(12B random), record, aad = machine_id) + # wire blob = nonce || ciphertext||tag +``` +ChaCha20-Poly1305 (IETF, 12-byte nonce) is the *same* AEAD Noise already uses, so it's +already vendored (`@noble/ciphers/chacha`) and in Go (`golang.org/x/crypto/chacha20poly1305`) +— no new primitive to bundle. Each device seals only a handful of records over its life (one +per reconnect), far below the 96-bit-nonce birthday bound, so a random nonce per seal is +safe. (B2.0 verifies the vendored bundle exports it and re-bundles if not, as we did for +pbkdf2.) +- Only a wallet-holder (has `wallet_secret` → `K_reg`) can produce a blob that **opens**. A + forged/garbage blob from someone without the wallet fails the AEAD → the fetcher **silently + drops it**. So the registry is self-authenticating with **no signature and no relay + verification** — the relay stays blind *and* stateless. +- `machine_id` is the AEAD **associated data**, binding each blob to its slot so the relay + can't move a blob between machine_ids. +- `machine_id` is the only plaintext (it's the GET slot key, and the relay already sees it at + attach). `name` / `host_pub` / `signal_url` live inside the ciphertext. + +### Agent — `mir up` auto-serves its own wallet + publishes +A machine that holds the wallet *is* one of your devices, so it needs no pairing to serve +you. On `mir up` with a wallet-rooted identity: +- **auto-pin its own wallet** as a served owner (`PinOwner(self_wallet)`) — so your clients + (same wallet, B1.4 bindings) are accepted with no SAS; +- **publish** its encrypted record on the registration. + +So a brand-new machine: `mir wallet import-phrase` → `mir up` → both accepts your clients +*and* appears in your list. Zero pairing. + +### Client — discover, merge, notify +`mir list` / `mir attach` fetch `GET /registry?wallet=self`, AEAD-open each blob (drop +failures), and merge the results with the local `machines.json`. A discovered machine's +`host_pub` arrives wallet-authenticated (only your wallet could seal it) → Noise-KK pins it +directly, **no TOFU/SAS**. `mir attach ` resolves the name from the registry. + +**Notify:** each device keeps a local "seen machine_ids" set; a machine_id newly present in +the registry prints `📣 new device "" joined your wallet` once. Local, no relay role. + +### Browser — auto-list +The browser already derives the wallet at sign-in (B1.5) → it has `wallet_secret` → `K_reg`. +It fetches the registry, decrypts, and **auto-populates "your machines" by name**, with the +same new-device notice. (The browser doesn't serve, so it only consumes the registry.) + +### Trade-off (accepted, the cost of statelessness) +Discovery shows **online** (or recently-online, within the grace period) machines. A +powered-off machine isn't listed until it reconnects — but you can't attach to an offline +machine anyway, so the loss is cosmetic (no "last seen, offline" row). + +### Revocation — honest in a stateless model +There's no persistent record to tombstone. "Revoke" = **turn the device off** (it stops +registering → disappears). For a real compromise (a leaked phrase), the only true recovery is +**rotating the wallet** (`mir keygen --wallet`). An explicit `mir device revoke` would be +theatre — an attacker with the phrase re-publishes anyway. The **notify** gives awareness; +rotation is the fix. + +--- + +## What stays unchanged (guardrails) +- **Relay stays blind AND stateless.** It carries one opaque blob per live registration and + serves a list; it never decrypts, verifies, or persists. No new durable state. +- **Acceptance is B1.4.** The agent still accepts any wallet-signed binding; the registry is + **discovery only**, so **LAN-direct keeps working fully offline** (no registry fetch on the + attach path). +- **`mir pair` / `mir add-machine` are kept** for cross-wallet (Track D seam) and manual + override; they're just no longer needed for your own devices. +- **Noise-KK transport + existing `testdata/` vectors** untouched. + +## Crypto discipline +- New `testdata/` vectors gate the registry crypto byte-identically Go↔JS: `K_reg` derivation + and a ChaCha20-Poly1305 seal/open over a fixed `(K_reg, nonce, record, machine_id)`. Go + (`golang.org/x/crypto/chacha20poly1305`) and JS (`@noble/ciphers/chacha`) must agree. +- `wallet_secret` is the existing 32-byte prf root; never stored beyond owner.json, never + sent to the relay. + +--- + +## Implementation order (TDD, small PRs) +- **B2.0** crypto: `identity.RegistryKey(secret)` + `SealRecord`/`OpenRecord` (XChaCha20- + Poly1305, machine_id AAD), Go + JS, `testdata/registry-*.json` vectors. +- **B2.1** relay (`mir-signal`): accept the blob as the agent's first registration message; + hold it in-memory on the `agentConn` (grace period on disconnect); `GET /registry?wallet=` + returns live blobs. Blind + stateless. Forward-through/listing tests. +- **B2.2** agent: on `mir up` with a wallet, auto-pin own wallet + build + publish the record; + republish on name/host change. +- **B2.3** client: registry fetch+open+merge in `mir list` / `mir attach`; `📣` notify on new + machine_id; resolve `attach ` via the registry. +- **B2.4** browser: fetch+decrypt+auto-list your machines by name + notify. +- **B2.5** e2e + deploy. **Deploy of the new `mir-signal` (registry endpoint) is live-infra — + Fredrik's hand, health-gated.** It is additive/backward-compatible (old clients ignore the + endpoint). + +--- + +## Non-goals (now) +- **Persistent registry / offline-machine listing.** Deliberately out — keeps the relay + stateless. (A "last seen" view would need durable state.) +- **Per-client-device revocation / tombstones.** Compromise recovery is wallet rotation. +- **Cross-wallet sharing** (Track D — the kept `mir pair`/SAS is the seam). +- **DHT-hosted registry** (Track C4 — the relay hosts it for now; the GET/blob shape is + DHT-portable later).