diff --git a/docs/superpowers/plans/2026-06-13-b1.4-b1.5-wallet-wire-plan.md b/docs/superpowers/plans/2026-06-13-b1.4-b1.5-wallet-wire-plan.md new file mode 100644 index 0000000..9885529 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-b1.4-b1.5-wallet-wire-plan.md @@ -0,0 +1,388 @@ +# B1.4 + B1.5 — Wallet `owner_id` on the wire — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> to implement this plan task-by-task with review between tasks. Steps use checkbox +> (`- [ ]`) syntax for tracking. + +**Goal:** make `owner_id` the base58 Solana wallet address end-to-end (relay, agent, CLI, +browser), with the agent recovering the X25519 Noise-KK pin from a wallet-signed binding, +and wallet control proven at pairing — without changing the Noise transport or its vectors. + +**Architecture:** owner_id stops being the X25519 hex. It becomes the base58 wallet. The +relay forwards an opaque `Binding` field on the offer. The agent verifies the binding +(`wallet == owner_id` + signature) and pins `binding.x25519` for KK. Pairing pins the +wallet, proven by an `auth` signature over the Noise channel binding. **No legacy path.** + +**Tech stack:** Go (`internal/{signal,agent,client,pairing,identity,cli}`), vanilla JS +(`web/src/{identity,pairing}`, `app.js`), `@noble` curves, `testdata/` interop vectors. + +**Decisions locked (2026-06-13):** (1) pairing auth in scope; (2) no legacy — wallet only, +binding always required; (3) cache the binding in `owner.json`. + +**Key cross-cutting facts:** +- `Binding` wire format is **frozen** by B1.2 (`testdata/wallet-binding.json`): + `{v,wallet,device,x25519,ts,sig}`, canonical = hand-built fixed-order string. Do not + change it. +- `device` = the owner's **stable device id** (not the agent's machine_id). Agent does NOT + check `device == machine_id`. Load-bearing checks: `wallet == owner_id` + valid sig. +- Go↔JS must stay byte-identical; `cd go && go test ./...` and `cd web && npm test` are the + gate. Regenerate Noise/pairing vectors only when they legitimately change: + `UPDATE_VECTORS=1 go test ./internal// -run `. + +--- + +## File structure + +| File | Responsibility | Change | +|---|---|---| +| `go/internal/signal/protocol.go` | SignalMsg wire shape | add `Binding` field | +| `go/internal/signal/server.go` | relay forward | carry `Binding` through (line ~517) | +| `go/internal/agent/runtime.go` | agent attach | binding verify + pin x25519 (replaces hex.Decode) | +| `go/internal/identity/auth.go` (new) | wallet auth sig over a challenge | `SignAuth`/`VerifyAuth` + domain | +| `go/internal/client/store.go` | owner.json | `DeviceID` + cached `BindingJSON`, signed in `SetFromSecret` | +| `go/internal/client/attach.go` | client attach | owner_id = wallet, offer carries binding | +| `go/internal/pairing/pairing.go` | NNpsk0 pairing | `PairClaim` msg1 + msg3 auth | +| `go/internal/cli/pair.go` | `mir pair` | pass wallet, pin base58 wallet | +| `web/src/identity.js` | sign-in | derive + cache wallet; keep wallet for session | +| `web/src/identity/auth.js` (new) | wallet auth sig | mirror Go `SignAuth`/`VerifyAuth` | +| `web/src/app.js` | browser attach | owner_id = wallet, offer carries binding | +| `web/src/pair.js`, `web/src/pairing/nnpsk0.js` | browser pairing | `PairClaim` + msg3 auth | +| `web/sw.js` | precache shell | add `auth.js` | +| `testdata/wallet-auth.json` (new) | auth vector | challenge→sig, Go↔JS gate | + +--- + +## Task 1 — B1.4.0: SignalMsg.Binding + relay passthrough + +**Files:** +- Modify: `go/internal/signal/protocol.go` +- Modify: `go/internal/signal/server.go` (forward at ~line 517) +- Test: `go/internal/signal/server_test.go` (or new forward test) + +- [ ] **Step 1: Write the failing test** — relay forwards `binding` browser→agent verbatim. + Use the existing server test harness (dial `/attach`, register a fake agent, send an offer + with a `binding`, assert the agent receives it). If a forwarding test already exists, add + a `Binding` assertion to it. + +- [ ] **Step 2: Run it, confirm it fails** (`Binding` dropped today). + Run: `cd go && go test ./internal/signal/ -run Forward -v` + +- [ ] **Step 3: Add the field.** In `protocol.go`: + ```go + 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 + } + ``` + In `server.go` ~line 517, add `Binding: m.Binding` to the reconstructed offer: + ```go + if !agentSend(live, bc.done, SignalMsg{Type: TypeOffer, Session: sess, SDP: m.SDP, Binding: m.Binding}) { + ``` + +- [ ] **Step 4: Run the test, confirm it passes.** `cd go && go test ./internal/signal/` + +- [ ] **Step 5: Commit.** `feat(signal): B1.4.0 opaque Binding passthrough on the offer` + +--- + +## Task 2 — B1.4.1: agent verifies binding, pins x25519 + +**Files:** +- Modify: `go/internal/agent/runtime.go` (`handleOffer`, ~line 335; add import `internal/identity`) +- Test: `go/internal/agent/runtime_test.go` (or new `binding_pin_test.go`) + +- [ ] **Step 1: Write the failing test** for a pure helper + `ownerPubFromBinding(bindingJSON, ownerID string) ([]byte, error)`. Table cases, using a + real wallet from `identity.DeriveWallet(secret)` + `wallet.SignBinding(deviceID, x25519hex, ts)`: + - good binding (wallet==ownerID) → returns the 32-byte x25519 pubkey bytes + - `binding.wallet != ownerID` → error + - tampered sig → error + - empty/missing binding → error + - malformed JSON → error + +- [ ] **Step 2: Run it, confirm it fails** (function undefined). + Run: `cd go && go test ./internal/agent/ -run BindingPin -v` + +- [ ] **Step 3: Implement the helper + wire it.** New helper in `runtime.go`: + ```go + // ownerPubFromBinding verifies the offer's wallet binding and returns the X25519 + // transport key to pin for Noise-KK. owner is the routing wallet (owner_id). + func ownerPubFromBinding(bindingJSON, owner string) ([]byte, error) { + if bindingJSON == "" { + return nil, fmt.Errorf("attach: missing wallet binding") + } + sb, err := identity.ParseSignedBinding([]byte(bindingJSON)) + if err != nil { + return nil, err + } + if sb.Wallet != owner { + return nil, fmt.Errorf("attach: binding wallet %q != owner_id %q", sb.Wallet, owner) + } + if err := identity.VerifyBinding(sb); err != nil { + return nil, err + } + return hex.DecodeString(sb.X25519) + } + ``` + In `handleOffer`, replace `ownerPub, err := hex.DecodeString(owner)` with + `ownerPub, err := ownerPubFromBinding(m.Binding, owner)`. Keep the `if err != nil { return }`. + Add `"github.com/srcful/terminal-relay/go/internal/identity"` to imports; drop `encoding/hex` + if now unused (it is still used? check — `agentSignalURL` and others; keep if used). + +- [ ] **Step 4: Run the test, confirm it passes; run the package.** `cd go && go test ./internal/agent/` + +- [ ] **Step 5: Commit.** `feat(agent): B1.4.1 verify wallet binding, pin x25519 at attach` + +--- + +## Task 3 — B1.4.2: client store device_id + cached binding; attach sends wallet owner_id + +**Files:** +- Modify: `go/internal/client/store.go` (Identity struct + `SetFromSecret`) +- Modify: `go/internal/client/attach.go` +- Test: `go/internal/client/store_test.go` + +- [ ] **Step 1: Write the failing test.** After `id.SetFromSecret(secret)`: + - `id.DeviceID` is non-empty and matches `^[0-9a-f]{16}$` + - `id.BindingJSON` parses via `identity.ParseSignedBinding`, `VerifyBinding` passes + - parsed `binding.wallet == id.WalletAddress`, `binding.x25519 == id.OwnerPubHex`, + `binding.device == id.DeviceID` + - `Rekey` produces a new device id + a fresh valid binding + +- [ ] **Step 2: Run it, confirm it fails.** `cd go && go test ./internal/client/ -run Binding -v` + +- [ ] **Step 3: Implement.** Add to `Identity`: + ```go + DeviceID string `json:"device_id,omitempty"` // stable per-identity device id (binding.device) + BindingJSON string `json:"binding,omitempty"` // cached wallet→x25519 binding (signed at re-key) + ``` + In `SetFromSecret`, after deriving the wallet, generate a device id if absent and sign + + cache the binding: + ```go + if i.DeviceID == "" { + d := make([]byte, 8) + if _, err := rand.Read(d); err != nil { + return err + } + i.DeviceID = hex.EncodeToString(d) + } + sb, err := w.SignBinding(i.DeviceID, i.OwnerPubHex, time.Now().Unix()) + if err != nil { + return err + } + rec, err := sb.JSON() + if err != nil { + return err + } + i.BindingJSON = rec + ``` + (Add `"time"` import.) `Rekey` builds a fresh `Identity{}` so DeviceID regenerates — good. + +- [ ] **Step 4: Run the test, confirm it passes.** `cd go && go test ./internal/client/` + +- [ ] **Step 5: Wire attach.** In `attach.go`, replace the owner_id/offer construction: + ```go + if !id.HasWallet() { + return nil, nil, nil, fmt.Errorf("this identity has no wallet; run `mir keygen --wallet`") + } + ownerID := id.WalletAddress + wsURL := "ws" + strings.TrimPrefix(m.SignalURL, "http") + + "/attach?owner_id=" + url.QueryEscape(ownerID) + + "&machine_id=" + url.QueryEscape(m.MachineID) + ... + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: id.BindingJSON}) + ``` + The KK initiator still uses `id.OwnerPriv()` (X25519) — unchanged. + +- [ ] **Step 6: Run go build + vet.** `cd go && go build ./... && go vet ./...` + +- [ ] **Step 7: Commit.** `feat(client): B1.4.2 wallet owner_id + cached binding in attach offer` + +--- + +## Task 4 — B1.4.3: pairing PairClaim + msg3 wallet auth (Go) + +**Files:** +- Create: `go/internal/identity/auth.go` + `auth_test.go` +- Modify: `go/internal/pairing/pairing.go` +- Modify: `go/internal/cli/pair.go` +- Test: `go/internal/pairing/pairing_test.go`, `go/internal/pairing/interop_test.go` (regen) + +- [ ] **Step 1: auth.go test first.** `SignAuth`/`VerifyAuth` over a challenge with the + `miranda/auth/v1` domain; verify good sig passes, tampered challenge/sig fail. + +- [ ] **Step 2: Implement auth.go.** + ```go + package identity + + import ( + "crypto/ed25519" + "fmt" + "github.com/srcful/terminal-relay/go/internal/base58" + ) + + const AuthDomain = "miranda/auth/v1" + + func authMessage(challenge []byte) []byte { return append([]byte(AuthDomain), challenge...) } + + // SignAuth proves control of the wallet over a fresh challenge (e.g. the pairing + // channel binding). Returns the raw 64-byte Ed25519 signature. + func (w *Wallet) SignAuth(challenge []byte) []byte { + return ed25519.Sign(w.Priv, authMessage(challenge)) + } + + // VerifyAuth checks a SignAuth signature against a base58 wallet address. + func VerifyAuth(walletBase58 string, challenge, sig []byte) error { + pub, err := base58.Decode(walletBase58) + if err != nil || len(pub) != ed25519.PublicKeySize { + return fmt.Errorf("auth: bad wallet key") + } + if len(sig) != ed25519.SignatureSize || !ed25519.Verify(ed25519.PublicKey(pub), authMessage(challenge), sig) { + return fmt.Errorf("auth: signature does not verify") + } + return nil + } + ``` + +- [ ] **Step 3: pairing.go — change signatures.** Add `PairClaim`: + ```go + type PairClaim struct { + Wallet string `json:"wallet"` // base58 owner wallet + } + ``` + `RunInitiator(ctx, mc, token []byte, wallet *identity.Wallet) (*AgentInfo, []byte, error)`: + msg1 payload = `json.Marshal(PairClaim{Wallet: wallet.Address})`; after reading msg2 and + obtaining `binding := hs.ChannelBinding()`, send msg3 = `wallet.SignAuth(binding)`; return + `info, binding, nil`. + `RunResponder(ctx, mc, token, info) (wallet string, binding []byte, err)`: read msg1 → + `PairClaim`, validate `base58.Decode(claim.Wallet)` is 32 bytes; send msg2 (AgentInfo); + `binding = hs.ChannelBinding()`; read msg3 = sig; `identity.VerifyAuth(claim.Wallet, binding, sig)`; + return `claim.Wallet, binding, nil`. (Import `internal/identity`, `internal/base58`.) + > msg3 is sent over `mc` after the handshake transport phase; it is a plain frame (the + > signature is public and binds to the transcript hash). Mirror the framing on the JS side. + +- [ ] **Step 4: cli/pair.go — wire wallet.** + - `pairInitiate`: `w, err := idn.Wallet()` (error → "run `mir keygen --wallet`"); call + `pairing.RunInitiator(ctx, mc, token, w)`. + - `pairRespond`: `wallet, binding, err := pairing.RunResponder(...)`; after SAS confirm, + `agent.PinOwner(dir, wallet)`; print `trusting owner %s` with the base58 wallet. + +- [ ] **Step 5: pairing_test.go** — in-memory initiator↔responder with a real wallet: assert + the responder returns the wallet base58, bindings match, auth verifies; negative case: a + responder fed a bad msg3 sig errors. Update `interop_test.go` if it asserts msg bytes; + regenerate with `UPDATE_VECTORS=1 go test ./internal/pairing/ -run ` only if + the change is legitimate. + +- [ ] **Step 6: Run.** `cd go && go test ./internal/identity/ ./internal/pairing/ ./internal/cli/` + +- [ ] **Step 7: Commit.** `feat(pairing): B1.4.3 PairClaim + wallet auth over channel binding` + +--- + +## Task 5 — B1.5: browser wallet at sign-in, attach binding, pairing claim+auth + +**Files:** +- Modify: `web/src/identity.js` +- Create: `web/src/identity/auth.js` +- Modify: `web/src/app.js` +- Modify: `web/src/pair.js`, `web/src/pairing/nnpsk0.js` +- Modify: `web/sw.js` (add `/src/identity/auth.js` to SHELL, sorted) +- Test: `web/test/auth.test.js`, extend pairing/attach tests + +- [ ] **Step 1: auth.js (mirror Go) + test first.** + ```js + // web/src/identity/auth.js — mirrors go/internal/identity/auth.go. + import { ed25519 } from '@noble/curves/ed25519'; + import { decode as b58decode } from '../wallet/base58.js'; + const AUTH_DOMAIN = 'miranda/auth/v1'; + const enc = new TextEncoder(); + function authMessage(challenge) { + const d = enc.encode(AUTH_DOMAIN); + const m = new Uint8Array(d.length + challenge.length); + m.set(d); m.set(challenge, d.length); + return m; + } + export function signAuth(wallet, challenge) { return ed25519.sign(authMessage(challenge), wallet.priv); } + export function verifyAuth(walletAddress, challenge, sig) { + const pub = b58decode(walletAddress); + if (pub.length !== 32 || sig.length !== 64) return false; + return ed25519.verify(sig, authMessage(challenge), pub); + } + ``` + `web/test/auth.test.js`: cross-check against `testdata/wallet-auth.json` (Task 6 writes the + vector; until then assert sign→verify round-trips and a known wallet/challenge from the + binding vector). + +- [ ] **Step 2: identity.js — derive + cache wallet at sign-in.** In `signInPasskey`, after + obtaining `prf`, also `const wallet = deriveWallet(prf)`; return `{ owner, wallet }` (keep + the wallet — incl. `priv` — in memory for the session, do NOT persist the private key). + Persist to localStorage: `tr_wallet` (address), `tr_device_id` (generate 8 random bytes + hex once), and `tr_binding` (the signed binding record). Update callers in `app.js` to the + new return shape. + +- [ ] **Step 3: app.js — attach by wallet.** owner_id query param = `wallet.address`; the + offer JSON gains `binding`: build/reuse the cached `tr_binding` (via + `signBinding(wallet, deviceId, bytesToHex(owner.pub), ts)`); KK initiator static stays + `owner` (X25519). Example: + ```js + const ownerId = wallet.address; + const ws = new WebSocket(wsBase(machine.signal) + '/attach?owner_id=' + + encodeURIComponent(ownerId) + '&machine_id=' + encodeURIComponent(machine.machine_id)); + ... + ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription.sdp, binding: bindingRecordJSON })); + ``` + +- [ ] **Step 4: pairing — PairClaim + auth.** In `pairing/nnpsk0.js runInitiator`, send msg1 + = `JSON.stringify({ wallet: wallet.address })` (was raw ownerPub); after msg2, compute + `binding = hs.binding()`, send msg3 = `signAuth(wallet, binding)`. Update `pair.js` to pass + the wallet and to pin the agent as before (browser pins the machine; the agent pins the + wallet). Mirror the Go framing exactly (msg3 = raw 64 bytes). + +- [ ] **Step 5: sw.js** — add `'/src/identity/auth.js'` to SHELL in sorted position. + +- [ ] **Step 6: Run web tests.** `cd web && npm test` + +- [ ] **Step 7: Commit.** `feat(web): B1.5 wallet owner_id + binding + pairing auth in browser` + +--- + +## Task 6 — vectors, e2e, full verification + +**Files:** +- Create: `testdata/wallet-auth.json` +- Test: `go/internal/identity/auth_test.go` (read vector), `web/test/auth.test.js` (read vector) + +- [ ] **Step 1: Add the auth vector.** Reuse the binding vector's wallet + (`C2XYPf…`/`wallet_priv fb0d9e…`). Pick a fixed challenge (e.g. 32 bytes of a known + channel-binding-shaped value), sign with Go, store `{wallet, wallet_priv, challenge(hex), + sig(base58)}`. Cross-check independently with `uv run --with cryptography` (Ed25519 over + `"miranda/auth/v1"||challenge`). Generate via `UPDATE_VECTORS=1 go test ./internal/identity/ -run Auth`. + +- [ ] **Step 2: Both gates green.** `cd go && go test ./...` and `cd web && npm test`. + Confirm Noise KK + wallet derivation + binding vectors are still byte-identical (untouched); + only `pairing` + new `auth` vectors changed. + +- [ ] **Step 3: gofmt + vet.** `cd go && gofmt -l . && go vet ./...` (no output from gofmt). + +- [ ] **Step 4: e2e smoke (Go).** Run the pairing e2e test + (`go test ./internal/pairing/ -run E2E`) and, if present, an attach e2e against an + in-process `mir-signal`. Manually: `mir keygen --wallet` → `mir pair` (responder) ↔ + `mir pair ` (initiator) → `mir attach` succeeds by wallet; a tampered binding is + rejected. + +- [ ] **Step 5: Commit.** `test(wallet): B1.4/B1.5 auth vector + e2e wallet attach` + +--- + +## Self-review notes +- **Spec coverage:** Tasks 1–6 cover every section of + `2026-06-12-b1.4-wallet-owner-id-wiring.md` (relay field, agent pin, client attach, + pairing auth, browser, vectors). No both-forms/legacy code (decision 2). +- **Type consistency:** `ownerPubFromBinding(bindingJSON, owner)`, `Identity.{DeviceID, + BindingJSON}`, `PairClaim{Wallet}`, `RunResponder → (wallet string, …)`, + `SignAuth/VerifyAuth` are referenced consistently across tasks. +- **Frozen format guard:** the `Binding` canonical/wire format is unchanged; only a new + `auth` signature scheme and the pairing msg1/msg3 framing are added. 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..15d7fd1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-b1.4-wallet-owner-id-wiring.md @@ -0,0 +1,158 @@ +# B1.4 — Wallet `owner_id` on the wire (relay routing + agent pin via binding) + +**Status:** Decisions locked (2026-06-13), ready to implement. Implements **B1.4 + B1.5** +of `2026-06-11-b1-wallet-identity.md` (Fork 2). The first slice that touches the live relay ++ agent. **Nothing in the Noise data plane changes** — the X25519 transport and its +`testdata/` vectors stay byte-identical. + +**Decisions (from review, 2026-06-13):** +1. **Pairing auth is in scope** — B1.4.3 (a wallet `auth` signature proving wallet control + at pairing) ships in this series, not a follow-up. +2. **No legacy.** `owner_id` is *always* the base58 wallet address and a signed binding is + *always* required. The hex-X25519 `owner_id` path is removed — there is no both-forms + acceptance and no `^[0-9a-f]{64}$` branch. An identity without a wallet must + `mir keygen --wallet` (re-pair to upgrade). +3. **Cache the binding.** The self-signed binding is stored once in `owner.json` (`ts` + fixed at re-key), not re-signed per attach. + +**Goal:** make `owner_id` the **base58 Solana wallet address**, with the agent recovering +the X25519 Noise-KK pin from a wallet-signed binding, without changing the Noise 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): always the base58 wallet address. Relay is + unaffected (opaque string match). +- **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 the binding's wallet, checks `binding.wallet == owner_id`, then + pins `binding.x25519` for `RunResponder`. The Noise data plane is **byte-identical** — + only the *source* of the pinned key changes. + +> **`device` is the owner's stable device id, not the agent's `machine_id`.** A single +> cached binding (decision 3) must work when the owner attaches to *any* of their machines, +> so the binding cannot be scoped to one target `machine_id`. `device` instead names which +> of the owner's devices holds `x25519` (the seam B2's multi-device registry generalizes). +> The agent therefore does **not** check `device == machine_id` — that check would break +> caching and adds no security: the KK handshake already requires the initiator to hold +> `x25519`'s private key, and only the wallet owner can mint a binding for it. `wallet == +> owner_id` + a valid signature are the load-bearing checks. + +### 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). + +### Attach: wallet only, binding always required +`owner_id` is base58; a valid `Binding` is **required** on the offer. The agent pins +`binding.x25519` after verification. A missing/invalid binding, or `binding.wallet != +owner_id`, fails the attach with a clear error — no silent fallback, no hex branch. + +### Agent paired-owner set +`PairedOwners` holds base58 wallet addresses. 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 `owner_id` string as today. + +### Auth freshness (what attach 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). The pairing NNpsk0 exchange gains a third message: after the handshake completes +and both sides hold the Noise **channel binding** (the anti-MITM transcript hash already +used for the SAS), the initiator sends `auth = Ed25519.sign(wallet.priv, "miranda/auth/v1" +|| channelBinding)` (signature base58, the wallet base58 alongside). The agent verifies +`auth` against the wallet over its own channel binding and pins that base58 wallet only if +it verifies. The channel binding is the freshest possible nonce — unique per session and +identical on both ends iff there is no MITM. (B1.2 already ships the `miranda/auth/v1` +domain tag.) + +The pairing message 1 payload changes from the raw 32-byte X25519 `ownerPub` to a JSON +`PairClaim{wallet}` (the base58 address). The agent no longer learns or pins an X25519 key +at pairing — that arrives via the signed binding at attach. Both sides change together and +the `pairing/` interop vectors regenerate. + +--- + +## 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` is + now base58. + +--- + +## Implementation order (TDD, small PRs) +1. **B1.4.0** add the opaque `Binding` field to `SignalMsg` + carry it through the relay + (`signal/server.go` reconstructs the forwarded offer — add `Binding: m.Binding`) + a + forward-through test (relay copies it browser→agent verbatim). Backward-compatible and + independently deployable. +2. **B1.4.1** agent attach (`agent/runtime.go handleOffer`): parse+verify the offer's + `binding` (`identity.ParseSignedBinding` + `identity.VerifyBinding`, `binding.wallet == + owner_id`) and pin `binding.x25519` for `RunResponder`. Replaces the `hex.Decode(owner)` + line. Table tests (good binding, wrong wallet, tampered sig, missing binding). +3. **B1.4.2** client store + attach: add a stable `device_id` and a cached `binding` to + `owner.json` (signed at `SetFromSecret`/`Rekey`); `client/attach.go` sends base58 + `owner_id = wallet_address` + the cached `binding` in the offer (errors if the identity + has no wallet). +4. **B1.4.3** pairing: change msg1 to `PairClaim{wallet}`, add the msg3 `auth` signature + over the channel binding, verify it agent-side, pin the base58 wallet. Both Go sides + (`pairing/pairing.go`, `cli/pair.go`) + regenerate `pairing/` interop vectors. +5. **B1.5** browser: derive + cache the wallet at sign-in; `attach` sends base58 `owner_id` + + a signed binding in the offer; pairing sends `PairClaim` + the `auth` signature. +6. **B1.4.4 / e2e** base58+binding attach works against a real `mir-signal`; agent rejects a + bad/missing binding; CLI↔CLI and browser↔CLI pair-then-attach by wallet; relay routing + unaffected; both vector gates green. + +**Deploy of the new `mir-signal` (the one-line `Binding` passthrough) is live-infra — +Fredrik's hand, health-gated redeploy. It is backward-compatible, so it can deploy before +the client cut.** Because there is no legacy path, agents and clients must be rebuilt +together with the wallet cut; existing X25519-only identities re-pair via `mir keygen +--wallet`.