diff --git a/docs/superpowers/specs/2026-06-12-b1.4-wallet-owner-id-wiring.md b/docs/superpowers/specs/2026-06-12-b1.4-wallet-owner-id-wiring.md new file mode 100644 index 0000000..93231dd --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-b1.4-wallet-owner-id-wiring.md @@ -0,0 +1,132 @@ +# B1.4 — Wallet `owner_id` on the wire (relay routing + agent pin via binding) + +**Status:** Draft for review (2026-06-12). Implements **B1.4** of +`2026-06-11-b1-wallet-identity.md` (Fork 2). The first slice that touches the live relay + +agent, so it lands behind both-forms acceptance and a re-pair-to-upgrade migration — +**no forced migration, nothing in the Noise data plane changes.** + +**Goal:** make `owner_id` the **base58 Solana wallet address**, accepted alongside the +legacy X25519-hex form, without changing the Noise-KK X25519 transport or its vectors. + +--- + +## How `owner_id` actually flows today (precise) + +`owner_id` has a **triple role** right now, and all three are literally the same string — +the owner's X25519 public key in hex: + +1. **Relay routing label.** Client dials `/attach?owner_id=&machine_id=…` + (`client/attach.go:24`). The relay treats `owner_id`/`machine_id` as **opaque** keys + (`signal/server.go:226`) — it matches strings to pair a browser/CLI with an agent and + never interprets them. The **registration proof** (`proofstore.go`) is a TOFU lock on + the `owner|machine` slot so a later rogue agent can't hijack it. +2. **Noise-KK pin (agent side).** The agent hex-decodes `owner_id` into `ownerPub` and + calls `peer.RunResponder(hostPriv, ownerPub)` (`agent/runtime.go:335`) — i.e. it pins + `owner_id` as the **expected initiator static key** for the KK handshake. +3. **Agent's paired-owner set.** `Config.PairedOwners` is a list of **hex X25519 owner + pubkeys** (`agent/store.go:22`); the agent refuses owners not in it. + +The client side is the mirror: `peer.RunInitiator(ownerPriv, hostPub)` with the X25519 +owner private key (`attach.go:85`). + +**Consequence:** to make `owner_id` a base58 wallet address, roles (2) and (3) must be +**decoupled** from the routing label — the agent can no longer recover the X25519 pin by +hex-decoding `owner_id`. The **binding** (B1.2) is exactly what reconnects them. + +--- + +## The change + +### owner_id → base58 wallet; X25519 pin comes from the binding +- **Routing label** (`owner_id` query param): base58 wallet address for prf-rooted + identities; legacy identities keep sending X25519-hex. Relay is unaffected (opaque). +- **KK pin:** the agent learns the owner's X25519 transport key from a **wallet-signed + binding** (`{v,wallet,device,x25519,ts,sig}`, B1.2) instead of from `owner_id`. It + verifies `sig` against `owner_id` (the wallet), checks `binding.wallet == owner_id` and + `binding.device == machine_id`, then pins `binding.x25519` for `RunResponder`. The Noise + data plane is **byte-identical** — only the *source* of the pinned key changes. + +### Where the binding travels — in the offer, relay stays blind +The KK responder must know the initiator's static key **before** `msg0`. The offer is the +only client→agent message that precedes the DataChannel/Noise. The relay **re-marshals a +typed `SignalMsg`** (`protocol.go`), so unknown JSON fields are dropped — therefore add an +**opaque** field: + +```go +type SignalMsg struct { + Type, Session, SDP, Reason string + Binding string `json:"binding,omitempty"` // opaque wallet-binding record; relay forwards, never reads +} +``` + +The relay copies `Binding` through without interpreting it (still blind — sees only +ciphertext + routing metadata). The agent reads it off the offer, verifies, and pins. + +> Rejected: a `binding` query param on `/attach` (ugly, ~300 B in the URL, logged by +> proxies); a pre-Noise message on the DataChannel (too late — KK needs the static first). + +### Form detection (both forms, Fork 2) +- `owner_id` matches `^[0-9a-f]{64}$` → **legacy path**, byte-identical to today: pin = + `hex.Decode(owner_id)`, no binding required. +- Otherwise → **wallet path**: `owner_id` is base58; a valid `Binding` is **required**; + pin = `binding.x25519` after verification. A missing/invalid binding fails the attach + with a clear error (no silent fallback). + +### Agent paired-owner set +`PairedOwners` gains base58 wallet entries alongside legacy X25519-hex. Pairing a wallet +pins the **base58 address**; at attach the binding authorizes one X25519 transport key +under it. (This is the seam B2's wallet-signed device registry generalizes — many devices +under one wallet.) `IsOwnerPinned` matches the normalized `owner_id` string as today. + +### Auth freshness (what B1.4 needs, and what it doesn't) +The KK handshake already authenticates the X25519 key holder — **only the owner holding +the X25519 private key can complete the session**, so a replayed binding alone buys an +attacker nothing for *attach*. The binding's wallet `sig` proves the wallet *authorized* +that X25519 key. So **attach needs no extra nonce.** + +**Pairing** is where wallet control must be freshly proven (first pin of a base58 +owner_id). There the owner adds `auth = Ed25519.sign(wallet.priv, "miranda/auth/v1" || +nonce)` over the pairing channel's fresh SAS/nonce, and the agent pins the base58 owner +only if `auth` verifies against it. (B1.2 already ships the `miranda/auth/v1` domain tag.) + +--- + +## What stays unchanged (guardrails) +- **Noise-KK handshake, X25519 transport derivation, existing `testdata/` vectors** — + untouched. CI gate (`go test ./...` + `npm test`) must stay green on current vectors. +- **Relay stays blind:** forwards `owner_id`/`machine_id`/`binding` opaquely; performs no + wallet verification (that is the agent's job, end-to-end). +- **Registration-proof semantics** unchanged (TOFU lock on `owner|machine`); `owner` may + now be base58. +- **Legacy path byte-identical:** an all-hex `owner_id` behaves exactly as today. + +--- + +## Implementation order (TDD, small PRs) +1. **B1.4.0** add the opaque `Binding` field to `SignalMsg` + a relay forward-through test + (relay copies it browser→agent verbatim, still re-marshals the rest). No behavior change. +2. **B1.4.1** agent attach: detect `owner_id` form; on the wallet path, parse+verify the + offer's `binding` (`identity.VerifyBinding`, `binding.wallet == owner_id`, + `binding.device == machine_id`) and pin `binding.x25519`. Legacy path unchanged. Unit + + table tests (good binding, wrong wallet, wrong device, tampered sig, missing binding). +3. **B1.4.2** client attach: prf-rooted identities send base58 `owner_id` + a self-signed + `binding` (device = `machine_id`, x25519 = `OwnerPub`) in the offer; legacy unchanged. +4. **B1.4.3** pairing: carry + verify the wallet `auth` signature so a base58 owner_id is + pinned only with proven wallet control. +5. **B1.4.4** e2e: legacy hex attach still works; new base58+binding attach works against a + real `mir-signal`; agent rejects a bad/missing binding; relay routing unaffected. + +Each step is independently shippable. **Deploy of the new `mir-signal` (the one-line +`Binding` field) is live-infra — Fredrik's hand, health-gated redeploy as before.** B1.5 +(browser: derive wallet, send base58 owner_id + binding, wallet-signed pairing auth) lands +after B1.4.1/2 so a phone can attach by wallet. + +## Open questions for review +- **Pairing auth scope:** include B1.4.3 (wallet `auth` over the pairing nonce) in this + spec's PRs, or split into a B1.4-auth follow-up once attach-by-wallet works end-to-end? +- **Legacy default:** keep sending hex `owner_id` for legacy identities indefinitely, or + nudge `mir` to re-key after N days? (Recommend: indefinite; re-pair only when the user + chooses `mir keygen --wallet`.) +- **One binding vs per-attach:** cache the self-signed binding in `owner.json` (ts fixed at + re-key) vs re-sign per attach with a fresh ts. (Recommend: cache; ts is informational, + the wallet sig is the trust, and a stable record is simpler to reason about.)