Skip to content
Merged
Show file tree
Hide file tree
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
388 changes: 388 additions & 0 deletions docs/superpowers/plans/2026-06-13-b1.4-b1.5-wallet-wire-plan.md
Original file line number Diff line number Diff line change
@@ -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/<pkg>/ -run <VectorTest>`.

---

## 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 <VectorTest>` 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 <code>` (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.
Loading
Loading