Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions docs/superpowers/specs/2026-06-12-b1.4-wallet-owner-id-wiring.md
Original file line number Diff line number Diff line change
@@ -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=<X25519 hex>&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.)
Loading