From 41277cd7f40f9e5cbc86831b9d0a79fde0a49bd0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 13 Jun 2026 08:05:30 +0200 Subject: [PATCH 1/5] feat(signal): B1.4.0 opaque Binding passthrough on the offer Carry an opaque wallet-binding record on the offer browser->agent. The relay copies it verbatim and never interprets it (still blind: sees only ciphertext + routing metadata). Backward-compatible; no behavior change when absent. Co-Authored-By: Claude Opus 4.8 --- go/internal/signal/protocol.go | 1 + go/internal/signal/server.go | 2 +- go/internal/signal/server_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/go/internal/signal/protocol.go b/go/internal/signal/protocol.go index 9cf6708..9dfba43 100644 --- a/go/internal/signal/protocol.go +++ b/go/internal/signal/protocol.go @@ -21,6 +21,7 @@ type SignalMsg struct { 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 } func (m SignalMsg) encode() ([]byte, error) { return json.Marshal(m) } diff --git a/go/internal/signal/server.go b/go/internal/signal/server.go index b3aedbc..0e1f7d3 100644 --- a/go/internal/signal/server.go +++ b/go/internal/signal/server.go @@ -514,7 +514,7 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { bc.fail("machine offline") return } - if !agentSend(live, bc.done, SignalMsg{Type: TypeOffer, Session: sess, SDP: m.SDP}) { + if !agentSend(live, bc.done, SignalMsg{Type: TypeOffer, Session: sess, SDP: m.SDP, Binding: m.Binding}) { bc.fail("machine offline") return } diff --git a/go/internal/signal/server_test.go b/go/internal/signal/server_test.go index 21602f1..ef0c143 100644 --- a/go/internal/signal/server_test.go +++ b/go/internal/signal/server_test.go @@ -109,6 +109,34 @@ func TestOfferReachesAgentAnswerReachesBrowser(t *testing.T) { } } +func TestOfferBindingReachesAgentVerbatim(t *testing.T) { + srv := httptest.NewServer(New().Handler()) + defer srv.Close() + + agent := dialJSON(t, wsURL(srv.URL, "/agent/signal", map[string]string{"owner_id": "o", "machine_id": "m"})) + if ready := readMsg(t, agent); ready.Type != TypeReady { + t.Fatalf("expected ready, got %q", ready.Type) + } + + browser := dialJSON(t, wsURL(srv.URL, "/attach", map[string]string{"owner_id": "o", "machine_id": "m"})) + attach := readMsg(t, agent) + if attach.Type != TypeAttach || attach.Session == "" { + t.Fatalf("expected attach with session, got %+v", attach) + } + + // Browser sends an offer carrying an opaque wallet-binding record. The relay + // must forward it verbatim without interpreting it. + const binding = "OPAQUE-WALLET-BINDING-RECORD" + writeMsg(t, browser, SignalMsg{Type: TypeOffer, SDP: "OFFER-SDP", Binding: binding}) + gotOffer := readMsg(t, agent) + if gotOffer.Type != TypeOffer || gotOffer.SDP != "OFFER-SDP" || gotOffer.Session != attach.Session { + t.Fatalf("agent got wrong offer: %+v", gotOffer) + } + if gotOffer.Binding != binding { + t.Fatalf("binding not forwarded verbatim: got %q want %q", gotOffer.Binding, binding) + } +} + func TestAttachOfflineMachineGetsError(t *testing.T) { srv := httptest.NewServer(New().Handler()) defer srv.Close() From 1e23beb5bae7475b2ecec972e222b0a7a748eb61 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 13 Jun 2026 08:10:38 +0200 Subject: [PATCH 2/5] feat(agent): B1.4.1 verify wallet binding, pin x25519 at attach The agent recovers the Noise-KK X25519 pin from the offer's wallet-signed binding instead of hex-decoding owner_id. Checks binding.wallet==owner_id and a valid signature, then pins binding.x25519. No legacy hex path. device is the owner's device id (not machine_id), so it is not checked here. Co-Authored-By: Claude Opus 4.8 --- go/internal/agent/binding_test.go | 125 ++++++++++++++++++++++++++++++ go/internal/agent/e2e_test.go | 13 ++-- go/internal/agent/runtime.go | 23 +++++- go/internal/agent/runtime_test.go | 11 +-- 4 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 go/internal/agent/binding_test.go diff --git a/go/internal/agent/binding_test.go b/go/internal/agent/binding_test.go new file mode 100644 index 0000000..7149324 --- /dev/null +++ b/go/internal/agent/binding_test.go @@ -0,0 +1,125 @@ +// go/internal/agent/binding_test.go +package agent + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +// TestOwnerPubFromBinding proves the agent recovers the Noise-KK X25519 pin from +// the offer's wallet-signed binding (not by hex-decoding owner_id). A binding is +// accepted only when its wallet == owner_id and its signature verifies; the pinned +// key is binding.x25519. device is the owner's device id, not the agent's +// machine_id, so it is not checked. +func TestOwnerPubFromBinding(t *testing.T) { + secret := bytes.Repeat([]byte{0x42}, 32) + w, err := identity.DeriveWallet(secret) + if err != nil { + t.Fatalf("DeriveWallet: %v", err) + } + _, pub, err := identity.DeriveOwnerKey(secret) + if err != nil { + t.Fatalf("DeriveOwnerKey: %v", err) + } + x25519hex := hex.EncodeToString(pub) + const device = "owner-laptop-1" + const ts = int64(1_700_000_000) + + sb, err := w.SignBinding(device, x25519hex, ts) + if err != nil { + t.Fatalf("SignBinding: %v", err) + } + goodJSON, err := sb.JSON() + if err != nil { + t.Fatalf("JSON: %v", err) + } + + // A second, unrelated wallet — its address is a valid base58 owner_id that + // does not match the binding's wallet. + other, err := identity.DeriveWallet(bytes.Repeat([]byte{0x07}, 32)) + if err != nil { + t.Fatalf("DeriveWallet(other): %v", err) + } + + // Tamper one character of the base58 signature inside the rendered JSON. + tampered := tamperSig(t, goodJSON, sb.Sig) + + cases := []struct { + name string + bindingJSON string + owner string + wantErr bool + wantPub []byte + }{ + {name: "good", bindingJSON: goodJSON, owner: w.Address, wantErr: false, wantPub: pub}, + {name: "wrong wallet", bindingJSON: goodJSON, owner: other.Address, wantErr: true}, + {name: "tampered signature", bindingJSON: tampered, owner: w.Address, wantErr: true}, + {name: "empty binding", bindingJSON: "", owner: w.Address, wantErr: true}, + {name: "malformed JSON", bindingJSON: "{not json", owner: w.Address, wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ownerPubFromBinding(tc.bindingJSON, tc.owner) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got pub %x", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, tc.wantPub) { + t.Fatalf("pin mismatch: got %x want %x", got, tc.wantPub) + } + }) + } +} + +// ownerBinding mints a wallet-rooted owner identity for handleOffer tests: it +// derives the wallet (owner_id) and the X25519 transport keypair from a shared +// secret, then signs a binding authorizing that x25519 under the wallet for the +// given device id. Returns the Noise initiator static keypair, the base58 owner_id +// to register/pin, and the signed-binding JSON to attach to the offer. +func ownerBinding(t *testing.T, secret []byte, device string) (priv, pub []byte, ownerID, bindingJSON string) { + t.Helper() + w, err := identity.DeriveWallet(secret) + if err != nil { + t.Fatalf("DeriveWallet: %v", err) + } + priv, pub, err = identity.DeriveOwnerKey(secret) + if err != nil { + t.Fatalf("DeriveOwnerKey: %v", err) + } + sb, err := w.SignBinding(device, hex.EncodeToString(pub), 1_700_000_000) + if err != nil { + t.Fatalf("SignBinding: %v", err) + } + bindingJSON, err = sb.JSON() + if err != nil { + t.Fatalf("binding JSON: %v", err) + } + return priv, pub, w.Address, bindingJSON +} + +// tamperSig flips one character of the signature substring inside the JSON so the +// record stays well-formed JSON but the signature no longer verifies. +func tamperSig(t *testing.T, jsonStr, sig string) string { + t.Helper() + if !strings.Contains(jsonStr, sig) { + t.Fatalf("sig %q not found in JSON %q", sig, jsonStr) + } + b := []byte(sig) + // base58 has no '0'; pick a char distinct from the original to guarantee a flip. + if b[0] == '1' { + b[0] = '2' + } else { + b[0] = '1' + } + return strings.Replace(jsonStr, sig, string(b), 1) +} diff --git a/go/internal/agent/e2e_test.go b/go/internal/agent/e2e_test.go index 273b185..2e09965 100644 --- a/go/internal/agent/e2e_test.go +++ b/go/internal/agent/e2e_test.go @@ -4,7 +4,6 @@ package agent import ( "bytes" "context" - "encoding/hex" "encoding/json" "net/http/httptest" "net/url" @@ -22,14 +21,16 @@ func TestEndToEndRealShellOverP2P(t *testing.T) { srv := httptest.NewServer(signal.New().Handler()) defer srv.Close() - // Owner (browser) identity + agent keystore with that owner pinned. - ownerPriv, ownerPub, _ := noise.GenerateStatic() + // Owner (browser) identity + agent keystore with that owner pinned. The owner + // is a real wallet: owner_id is the base58 wallet address, and the Noise pin is + // recovered from a wallet-signed binding carried on the offer (B1.4.1). + ownerPriv, _, ownerID, bindingJSON := ownerBinding(t, bytes.Repeat([]byte{0x11}, 32), "owner-device-e2e") dir := t.TempDir() cfg, err := LoadOrInit(dir, "e2e-machine", srv.URL) if err != nil { t.Fatal(err) } - if err := PinOwner(dir, hex.EncodeToString(ownerPub)); err != nil { + if err := PinOwner(dir, ownerID); err != nil { t.Fatal(err) } cfg, _ = LoadOrInit(dir, "e2e-machine", srv.URL) // reload with the pinned owner @@ -43,7 +44,7 @@ func TestEndToEndRealShellOverP2P(t *testing.T) { // Browser-stand-in: attach, offer, await answer, open DataChannel, Noise init. bws := "ws" + strings.TrimPrefix(srv.URL, "http") + - "/attach?owner_id=" + url.QueryEscape(hex.EncodeToString(ownerPub)) + + "/attach?owner_id=" + url.QueryEscape(ownerID) + "&machine_id=" + url.QueryEscape(cfg.MachineID) bc, _, err := websocket.Dial(ctx, bws, nil) if err != nil { @@ -59,7 +60,7 @@ func TestEndToEndRealShellOverP2P(t *testing.T) { if err != nil { t.Fatal(err) } - offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP}) + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: bindingJSON}) if err := bc.Write(ctx, websocket.MessageText, offerMsg); err != nil { t.Fatal(err) } diff --git a/go/internal/agent/runtime.go b/go/internal/agent/runtime.go index b1fa38d..513748b 100644 --- a/go/internal/agent/runtime.go +++ b/go/internal/agent/runtime.go @@ -17,10 +17,31 @@ import ( "github.com/coder/websocket" "github.com/pion/webrtc/v4" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/peer" "github.com/srcful/terminal-relay/go/internal/signal" ) +// 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). There +// is no legacy hex path: a valid binding whose wallet == owner_id is required. +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) +} + // minHealthyUptime is the shortest a signaling connection must stay up before we // treat it as a genuinely healthy session whose drop warrants a prompt reconnect. // A connection the relay accepts then drops faster than this (a same-identity @@ -332,7 +353,7 @@ func (rt *Runtime) handleOffer(ctx context.Context, c *websocket.Conn, m signal. return // no P2P path (strict P2P) — give up this attach } - ownerPub, err := hex.DecodeString(owner) + ownerPub, err := ownerPubFromBinding(m.Binding, owner) if err != nil { return } diff --git a/go/internal/agent/runtime_test.go b/go/internal/agent/runtime_test.go index 1e3c8a6..46d1175 100644 --- a/go/internal/agent/runtime_test.go +++ b/go/internal/agent/runtime_test.go @@ -4,7 +4,6 @@ package agent import ( "bytes" "context" - "encoding/hex" "encoding/json" "net/http/httptest" "net/url" @@ -28,12 +27,14 @@ func TestRuntimeReclaimsAttachOnDisconnect(t *testing.T) { srv := httptest.NewServer(signal.New().Handler()) defer srv.Close() - ownerPriv, ownerPub, _ := noise.GenerateStatic() + // Wallet-rooted owner: owner_id is the base58 wallet address and the Noise pin + // is recovered from a wallet-signed binding carried on the offer (B1.4.1). + ownerPriv, _, ownerID, bindingJSON := ownerBinding(t, bytes.Repeat([]byte{0x22}, 32), "owner-device-leak") dir := t.TempDir() if _, err := LoadOrInit(dir, "leak-machine", srv.URL); err != nil { t.Fatal(err) } - if err := PinOwner(dir, hex.EncodeToString(ownerPub)); err != nil { + if err := PinOwner(dir, ownerID); err != nil { t.Fatal(err) } cfg, _ := LoadOrInit(dir, "leak-machine", srv.URL) @@ -52,7 +53,7 @@ func TestRuntimeReclaimsAttachOnDisconnect(t *testing.T) { defer dialCancel() bws := "ws" + strings.TrimPrefix(srv.URL, "http") + - "/attach?owner_id=" + url.QueryEscape(hex.EncodeToString(ownerPub)) + + "/attach?owner_id=" + url.QueryEscape(ownerID) + "&machine_id=" + url.QueryEscape(cfg.MachineID) bc, _, err := websocket.Dial(dialCtx, bws, nil) if err != nil { @@ -67,7 +68,7 @@ func TestRuntimeReclaimsAttachOnDisconnect(t *testing.T) { if err != nil { t.Fatal(err) } - offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP}) + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: bindingJSON}) if err := bc.Write(dialCtx, websocket.MessageText, offerMsg); err != nil { t.Fatal(err) } From cdf1d479ff5d7b9af669c514491db0833f637379 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 13 Jun 2026 08:14:39 +0200 Subject: [PATCH 3/5] feat(client): B1.4.2 wallet owner_id + cached binding in attach offer owner.json gains a stable device_id and a cached wallet->x25519 binding, signed at SetFromSecret/Rekey. mir attach now dials owner_id= and carries the signed binding on the offer; the KK initiator still uses the X25519 transport key. Errors if the identity has no wallet. The two client e2e tests pin the wallet address (not the hex transport key) to match the agent's binding.wallet==owner_id check (B1.4.1). Co-Authored-By: Claude Opus 4.8 --- go/internal/client/attach.go | 9 ++- go/internal/client/binding_test.go | 90 ++++++++++++++++++++++++++++++ go/internal/client/e2e_mux_test.go | 2 +- go/internal/client/e2e_test.go | 2 +- go/internal/client/store.go | 19 +++++++ 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 go/internal/client/binding_test.go diff --git a/go/internal/client/attach.go b/go/internal/client/attach.go index 9288727..69a63f6 100644 --- a/go/internal/client/attach.go +++ b/go/internal/client/attach.go @@ -21,9 +21,12 @@ import ( // DataChannel with the named machine's agent, runs the Noise KK initiator, and // returns the established session. Call cleanup when done. func Attach(ctx context.Context, m Machine, id *Identity, ice []peer.ICEServer) (mc *peer.DataChannel, sess *noise.Session, cleanup func(), err error) { - ownerPubHex := id.OwnerPubHex + 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(ownerPubHex) + + "/attach?owner_id=" + url.QueryEscape(ownerID) + "&machine_id=" + url.QueryEscape(m.MachineID) c, _, err := websocket.Dial(ctx, wsURL, nil) @@ -44,7 +47,7 @@ func Attach(ctx context.Context, m Machine, id *Identity, ice []peer.ICEServer) cleanup() return nil, nil, nil, err } - offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP}) + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: id.BindingJSON}) if err := c.Write(ctx, websocket.MessageText, offerMsg); err != nil { cleanup() return nil, nil, nil, err diff --git a/go/internal/client/binding_test.go b/go/internal/client/binding_test.go new file mode 100644 index 0000000..d43c18d --- /dev/null +++ b/go/internal/client/binding_test.go @@ -0,0 +1,90 @@ +// go/internal/client/binding_test.go +package client + +import ( + "regexp" + "testing" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +var deviceIDRe = regexp.MustCompile(`^[0-9a-f]{16}$`) + +// TestSetFromSecretCachesBinding asserts that rooting an identity in a secret +// also mints a stable device id and a cached wallet->x25519 binding that +// verifies and matches the identity's wallet/transport key/device. +func TestSetFromSecretCachesBinding(t *testing.T) { + secret := make([]byte, 32) + for i := range secret { + secret[i] = byte(i + 1) + } + id := &Identity{} + if err := id.SetFromSecret(secret); err != nil { + t.Fatal(err) + } + + if !deviceIDRe.MatchString(id.DeviceID) { + t.Fatalf("device id %q does not match ^[0-9a-f]{16}$", id.DeviceID) + } + if id.BindingJSON == "" { + t.Fatal("binding json was not cached") + } + + sb, err := identity.ParseSignedBinding([]byte(id.BindingJSON)) + if err != nil { + t.Fatalf("parse binding: %v", err) + } + if err := identity.VerifyBinding(sb); err != nil { + t.Fatalf("verify binding: %v", err) + } + if sb.Wallet != id.WalletAddress { + t.Fatalf("binding wallet %q != identity wallet %q", sb.Wallet, id.WalletAddress) + } + if sb.X25519 != id.OwnerPubHex { + t.Fatalf("binding x25519 %q != owner pub %q", sb.X25519, id.OwnerPubHex) + } + if sb.Device != id.DeviceID { + t.Fatalf("binding device %q != device id %q", sb.Device, id.DeviceID) + } +} + +// TestRekeyRotatesDeviceAndBinding asserts that Rekey mints a fresh device id and +// a fresh valid binding distinct from a prior identity. +func TestRekeyRotatesDeviceAndBinding(t *testing.T) { + dir := t.TempDir() + + prior := &Identity{} + priorSecret := make([]byte, 32) + for i := range priorSecret { + priorSecret[i] = byte(0xA0 + i) + } + if err := prior.SetFromSecret(priorSecret); err != nil { + t.Fatal(err) + } + + rekeyed, err := Rekey(dir) + if err != nil { + t.Fatal(err) + } + + if rekeyed.DeviceID == "" || !deviceIDRe.MatchString(rekeyed.DeviceID) { + t.Fatalf("rekeyed device id %q invalid", rekeyed.DeviceID) + } + if rekeyed.DeviceID == prior.DeviceID { + t.Fatalf("rekey did not rotate device id: %q", rekeyed.DeviceID) + } + if rekeyed.BindingJSON == "" || rekeyed.BindingJSON == prior.BindingJSON { + t.Fatal("rekey did not produce a fresh binding") + } + + sb, err := identity.ParseSignedBinding([]byte(rekeyed.BindingJSON)) + if err != nil { + t.Fatalf("parse rekeyed binding: %v", err) + } + if err := identity.VerifyBinding(sb); err != nil { + t.Fatalf("verify rekeyed binding: %v", err) + } + if sb.Wallet != rekeyed.WalletAddress || sb.X25519 != rekeyed.OwnerPubHex || sb.Device != rekeyed.DeviceID { + t.Fatalf("rekeyed binding fields mismatch: %+v", sb) + } +} diff --git a/go/internal/client/e2e_mux_test.go b/go/internal/client/e2e_mux_test.go index 38b998c..34ba0cd 100644 --- a/go/internal/client/e2e_mux_test.go +++ b/go/internal/client/e2e_mux_test.go @@ -21,7 +21,7 @@ func startAgent(t *testing.T, ctx context.Context, srvURL, name string, id *Iden if err != nil { t.Fatal(err) } - if err := agent.PinOwner(dir, id.OwnerPubHex); err != nil { + if err := agent.PinOwner(dir, id.WalletAddress); err != nil { t.Fatal(err) } cfg, _ = agent.LoadOrInit(dir, name, srvURL) diff --git a/go/internal/client/e2e_test.go b/go/internal/client/e2e_test.go index 6cfb7e3..73a9c45 100644 --- a/go/internal/client/e2e_test.go +++ b/go/internal/client/e2e_test.go @@ -29,7 +29,7 @@ func TestEndToEndTrClientDrivesRealShell(t *testing.T) { if err != nil { t.Fatal(err) } - if err := agent.PinOwner(agentDir, id.OwnerPubHex); err != nil { + if err := agent.PinOwner(agentDir, id.WalletAddress); err != nil { t.Fatal(err) } acfg, _ = agent.LoadOrInit(agentDir, "e2e-box", srv.URL) diff --git a/go/internal/client/store.go b/go/internal/client/store.go index 8966b4c..be0d8b9 100644 --- a/go/internal/client/store.go +++ b/go/internal/client/store.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/srcful/terminal-relay/go/internal/identity" ) @@ -22,6 +23,8 @@ type Identity struct { OwnerPrivHex string `json:"owner_priv"` // X25519 transport private key (hex) OwnerPubHex string `json:"owner_pub"` // X25519 transport public key (hex) — the legacy owner_id WalletAddress string `json:"wallet_address,omitempty"` // base58 Solana address — the wallet owner_id + 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) } // Machine is a known agent (machines.json), pinned by host pubkey. @@ -79,6 +82,22 @@ func (i *Identity) SetFromSecret(secret []byte) error { i.OwnerPrivHex = hex.EncodeToString(priv) i.OwnerPubHex = hex.EncodeToString(pub) i.WalletAddress = w.Address + 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 return nil } From 4c84b03f9c0122b59317159af90bf56b5a846675 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 13 Jun 2026 08:20:44 +0200 Subject: [PATCH 4/5] feat(pairing): B1.4.3 PairClaim + wallet auth over channel binding Pairing now pins the base58 wallet, proven by an Ed25519 auth signature over the Noise channel binding (domain miranda/auth/v1). msg1 carries PairClaim{wallet}; a new msg3 carries the signature. The agent pins the wallet only if auth verifies. Adds identity.SignAuth/VerifyAuth. Co-Authored-By: Claude Opus 4.8 --- go/internal/cli/pair.go | 14 ++-- go/internal/identity/auth.go | 34 +++++++++ go/internal/identity/auth_test.go | 79 +++++++++++++++++++++ go/internal/pairing/e2e_test.go | 24 ++++--- go/internal/pairing/interop_test.go | 44 +++++++++--- go/internal/pairing/pairing.go | 69 ++++++++++++++----- go/internal/pairing/pairing_test.go | 103 +++++++++++++++++++++++++--- testdata/pair-interop.json | 10 +-- 8 files changed, 322 insertions(+), 55 deletions(-) create mode 100644 go/internal/identity/auth.go create mode 100644 go/internal/identity/auth_test.go diff --git a/go/internal/cli/pair.go b/go/internal/cli/pair.go index d20ab6c..9c1f2b2 100644 --- a/go/internal/cli/pair.go +++ b/go/internal/cli/pair.go @@ -3,7 +3,6 @@ package cli import ( "bufio" "context" - "encoding/hex" "flag" "fmt" "io" @@ -143,6 +142,10 @@ func (a *app) pairInitiate(dir, codeStr string, gate sasGate) error { if err != nil { return err } + w, err := idn.Wallet() + if err != nil { + return fmt.Errorf("pairing needs a wallet; run `mir keygen --wallet`: %w", err) + } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() mc, closeConn, err := pairing.DialPair(ctx, signalURL, pairing.RoomID(token)) @@ -150,7 +153,7 @@ func (a *app) pairInitiate(dir, codeStr string, gate sasGate) error { return err } defer closeConn() - info, binding, err := pairing.RunInitiator(ctx, mc, token, idn.OwnerPub()) + info, binding, err := pairing.RunInitiator(ctx, mc, token, w) if err != nil { return err } @@ -199,11 +202,10 @@ func (a *app) pairRespond(dir, name, signalURL, webURL string, gate sasGate) err defer closeConn() info := pairing.AgentInfo{HostPubHex: cfg.HostPubHex, MachineID: cfg.MachineID, Name: cfg.MachineName} - ownerPub, binding, err := pairing.RunResponder(ctx, mc, token, info) + wallet, binding, err := pairing.RunResponder(ctx, mc, token, info) if err != nil { return err } - ownerHex := hex.EncodeToString(ownerPub) // Show the safety number FIRST, then require confirmation BEFORE pinning the // owner — otherwise the printed number is advisory and a MITM is never caught. @@ -212,9 +214,9 @@ func (a *app) pairRespond(dir, name, signalURL, webURL string, gate sasGate) err if ok, reason := gate.confirm(safety, a.out); !ok { return fmt.Errorf("pairing cancelled: %s", reason) } - if err := agent.PinOwner(dir, ownerHex); err != nil { + if err := agent.PinOwner(dir, wallet); err != nil { return err } - fmt.Fprintf(a.out, "✓ paired — trusting owner %s…\n", ownerHex[:16]) + fmt.Fprintf(a.out, "✓ paired — trusting wallet %s…\n", wallet[:16]) return nil } diff --git a/go/internal/identity/auth.go b/go/internal/identity/auth.go new file mode 100644 index 0000000..141179b --- /dev/null +++ b/go/internal/identity/auth.go @@ -0,0 +1,34 @@ +// go/internal/identity/auth.go — wallet control proof over a fresh challenge +// (e.g. a pairing channel binding). Mirrors web/src/identity/auth.js. +package identity + +import ( + "crypto/ed25519" + "fmt" + + "github.com/srcful/terminal-relay/go/internal/base58" +) + +// AuthDomain separates auth signatures from binding signatures and any other use +// of the wallet key. Signed bytes = AuthDomain || challenge. +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. 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 +} diff --git a/go/internal/identity/auth_test.go b/go/internal/identity/auth_test.go new file mode 100644 index 0000000..29bdfb4 --- /dev/null +++ b/go/internal/identity/auth_test.go @@ -0,0 +1,79 @@ +// go/internal/identity/auth_test.go +package identity + +import ( + "bytes" + "testing" +) + +func mustWallet(t *testing.T) *Wallet { + t.Helper() + prf := make([]byte, 32) + for i := range prf { + prf[i] = byte(i) + } + w, err := DeriveWallet(prf) + if err != nil { + t.Fatalf("DeriveWallet: %v", err) + } + return w +} + +func TestSignAuthVerifies(t *testing.T) { + w := mustWallet(t) + challenge := []byte("a fresh channel binding") + sig := w.SignAuth(challenge) + if len(sig) != 64 { + t.Fatalf("SignAuth len = %d, want 64", len(sig)) + } + if err := VerifyAuth(w.Address, challenge, sig); err != nil { + t.Fatalf("VerifyAuth(valid) = %v, want nil", err) + } +} + +func TestVerifyAuthRejectsTamperedChallenge(t *testing.T) { + w := mustWallet(t) + challenge := []byte("the real challenge") + sig := w.SignAuth(challenge) + tampered := bytes.Clone(challenge) + tampered[0] ^= 0xff + if err := VerifyAuth(w.Address, tampered, sig); err == nil { + t.Fatal("VerifyAuth(tampered challenge) = nil, want error") + } +} + +func TestVerifyAuthRejectsTamperedSig(t *testing.T) { + w := mustWallet(t) + challenge := []byte("the real challenge") + sig := w.SignAuth(challenge) + sig[0] ^= 0xff + if err := VerifyAuth(w.Address, challenge, sig); err == nil { + t.Fatal("VerifyAuth(tampered sig) = nil, want error") + } +} + +func TestVerifyAuthRejectsWrongWallet(t *testing.T) { + w := mustWallet(t) + challenge := []byte("the real challenge") + sig := w.SignAuth(challenge) + + prf2 := make([]byte, 32) + for i := range prf2 { + prf2[i] = byte(255 - i) + } + other, err := DeriveWallet(prf2) + if err != nil { + t.Fatalf("DeriveWallet: %v", err) + } + if err := VerifyAuth(other.Address, challenge, sig); err == nil { + t.Fatal("VerifyAuth(wrong wallet) = nil, want error") + } +} + +func TestVerifyAuthRejectsBadWalletEncoding(t *testing.T) { + w := mustWallet(t) + sig := w.SignAuth([]byte("x")) + if err := VerifyAuth("not-base58-0OIl", []byte("x"), sig); err == nil { + t.Fatal("VerifyAuth(bad wallet encoding) = nil, want error") + } +} diff --git a/go/internal/pairing/e2e_test.go b/go/internal/pairing/e2e_test.go index 137c1eb..25b0990 100644 --- a/go/internal/pairing/e2e_test.go +++ b/go/internal/pairing/e2e_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/noise" "github.com/srcful/terminal-relay/go/internal/pairing" "github.com/srcful/terminal-relay/go/internal/signal" @@ -20,7 +21,14 @@ func TestPairThroughSignalingServer(t *testing.T) { token := pairing.NewToken() code := pairing.EncodeCode(srv.URL, token) - _, ownerPub, _ := noise.GenerateStatic() + prf := make([]byte, 32) + for i := range prf { + prf[i] = byte(i + 1) + } + wallet, err := identity.DeriveWallet(prf) + if err != nil { + t.Fatal(err) + } _, hostPub, _ := noise.GenerateStatic() info := pairing.AgentInfo{HostPubHex: hex.EncodeToString(hostPub), MachineID: "mid42", Name: "box"} @@ -28,16 +36,16 @@ func TestPairThroughSignalingServer(t *testing.T) { defer cancel() // Agent side (responder). - gotOwner := make(chan []byte, 1) + gotWallet := make(chan string, 1) go func() { mc, closeConn, err := pairing.DialPair(ctx, srv.URL, pairing.RoomID(token)) if err != nil { return } defer closeConn() - op, _, err := pairing.RunResponder(ctx, mc, token, info) + wal, _, err := pairing.RunResponder(ctx, mc, token, info) if err == nil { - gotOwner <- op + gotWallet <- wal } }() time.Sleep(150 * time.Millisecond) // let the agent register the room first @@ -52,7 +60,7 @@ func TestPairThroughSignalingServer(t *testing.T) { t.Fatal(err) } defer closeConn() - got, _, err := pairing.RunInitiator(ctx, mc, tok, ownerPub) + got, _, err := pairing.RunInitiator(ctx, mc, tok, wallet) if err != nil { t.Fatalf("client pair: %v", err) } @@ -60,9 +68,9 @@ func TestPairThroughSignalingServer(t *testing.T) { t.Fatalf("client pinned wrong info: %+v", got) } select { - case op := <-gotOwner: - if hex.EncodeToString(op) != hex.EncodeToString(ownerPub) { - t.Fatal("agent pinned wrong owner") + case wal := <-gotWallet: + if wal != wallet.Address { + t.Fatalf("agent pinned wrong wallet: got %s want %s", wal, wallet.Address) } case <-time.After(3 * time.Second): t.Fatal("agent never paired") diff --git a/go/internal/pairing/interop_test.go b/go/internal/pairing/interop_test.go index f11c656..15ec00c 100644 --- a/go/internal/pairing/interop_test.go +++ b/go/internal/pairing/interop_test.go @@ -13,6 +13,7 @@ import ( "github.com/flynn/noise" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/sas" ) @@ -20,8 +21,10 @@ var ( fxToken = mustHex("00112233445566778899aabbccddeeff") // 16-byte token fxInitEph = mustHex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") fxRespEph = mustHex("2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40") - fxOwner = mustHex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf") // owner pub - fxInfo = `{"host_pub":"5051525354555657585950515253545550515253545556575859505152535455","machine_id":"m42","name":"box"}` + // fxWalletPRF is the fixed 32-byte prf root the fixture wallet derives from, + // so msg1 (PairClaim{wallet}) and msg3 (the auth signature) are deterministic. + fxWalletPRF = mustHex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf") + fxInfo = `{"host_pub":"5051525354555657585950515253545550515253545556575859505152535455","machine_id":"m42","name":"box"}` ) func mustHex(s string) []byte { b, _ := hex.DecodeString(s); return b } @@ -39,12 +42,14 @@ func (r *fixedReader) Read(p []byte) (int, error) { type pairVectors struct { Token string `json:"token"` - OwnerPub string `json:"owner_pub"` + Wallet string `json:"wallet"` // base58 wallet the claim carries + Claim string `json:"claim"` // msg1 payload (JSON PairClaim) before Noise framing InfoJSON string `json:"info_json"` RoomID string `json:"room_id"` PSK string `json:"psk"` - Msg1 string `json:"msg1"` - Msg2 string `json:"msg2"` + Msg1 string `json:"msg1"` // Noise-framed PairClaim + Msg2 string `json:"msg2"` // Noise-framed AgentInfo + Msg3 string `json:"msg3"` // raw 64-byte wallet auth signature over the binding SafetyNum string `json:"safety_number"` } @@ -65,9 +70,18 @@ func nnpsk0(initiator bool) *noise.HandshakeState { func runFixed(t *testing.T) pairVectors { t.Helper() + wallet, err := identity.DeriveWallet(fxWalletPRF) + if err != nil { + t.Fatal(err) + } + claim, err := json.Marshal(PairClaim{Wallet: wallet.Address}) + if err != nil { + t.Fatal(err) + } + ini := nnpsk0(true) res := nnpsk0(false) - msg1, _, _, err := ini.WriteMessage(nil, fxOwner) + msg1, _, _, err := ini.WriteMessage(nil, claim) if err != nil { t.Fatal(err) } @@ -81,12 +95,21 @@ func runFixed(t *testing.T) pairVectors { if _, _, _, err := ini.ReadMessage(nil, msg2); err != nil { t.Fatal(err) } + // msg3 is the raw wallet auth signature over the channel binding. Both ends + // derive the same binding, so signing the initiator's binding is canonical. + binding := ini.ChannelBinding() + msg3 := wallet.SignAuth(binding) + if err := identity.VerifyAuth(wallet.Address, binding, msg3); err != nil { + t.Fatalf("fixture auth must verify: %v", err) + } psk := sha256.Sum256(append([]byte("terminal-relay/pair/psk"), fxToken...)) return pairVectors{ - Token: hex.EncodeToString(fxToken), OwnerPub: hex.EncodeToString(fxOwner), - InfoJSON: fxInfo, RoomID: RoomID(fxToken), PSK: hex.EncodeToString(psk[:]), + Token: hex.EncodeToString(fxToken), Wallet: wallet.Address, + Claim: string(claim), InfoJSON: fxInfo, RoomID: RoomID(fxToken), + PSK: hex.EncodeToString(psk[:]), Msg1: hex.EncodeToString(msg1), Msg2: hex.EncodeToString(msg2), - SafetyNum: sas.FromBinding(ini.ChannelBinding()), + Msg3: hex.EncodeToString(msg3), + SafetyNum: sas.FromBinding(binding), } } @@ -108,7 +131,8 @@ func TestPairInteropVectorsStable(t *testing.T) { } var want pairVectors _ = json.Unmarshal(raw, &want) - if v.Msg1 != want.Msg1 || v.Msg2 != want.Msg2 || v.SafetyNum != want.SafetyNum { + if v.Msg1 != want.Msg1 || v.Msg2 != want.Msg2 || v.Msg3 != want.Msg3 || + v.Claim != want.Claim || v.Wallet != want.Wallet || v.SafetyNum != want.SafetyNum { t.Fatalf("Go pairing drifted from committed vectors") } _ = io.Discard diff --git a/go/internal/pairing/pairing.go b/go/internal/pairing/pairing.go index 00f883f..16614d7 100644 --- a/go/internal/pairing/pairing.go +++ b/go/internal/pairing/pairing.go @@ -9,6 +9,8 @@ import ( "github.com/flynn/noise" + "github.com/srcful/terminal-relay/go/internal/base58" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/peer" ) @@ -23,6 +25,13 @@ type AgentInfo struct { Name string `json:"name"` } +// PairClaim is the initiator's msg1 payload: the base58 wallet it claims to own. +// The claim is later proven by a wallet auth signature over the channel binding +// (msg3), so the responder pins the wallet only after verifying control of it. +type PairClaim struct { + Wallet string `json:"wallet"` +} + func newHandshake(initiator bool, token []byte) (*noise.HandshakeState, error) { return noise.NewHandshakeState(noise.Config{ CipherSuite: cipherSuite, @@ -35,15 +44,18 @@ func newHandshake(initiator bool, token []byte) (*noise.HandshakeState, error) { }) } -// RunInitiator is the client side: it sends ownerPub and returns the agent's -// info plus the Noise channel binding (a transcript hash identical on both ends -// iff there was no MITM — use sas.FromBinding to show a safety number). -func RunInitiator(ctx context.Context, mc peer.MsgConn, token, ownerPub []byte) (*AgentInfo, []byte, error) { +// RunInitiator is the client side: it sends a PairClaim for its wallet (msg1), +// reads the agent's info (msg2), then proves control of the wallet with an auth +// signature over the channel binding (msg3). It returns the agent's info plus the +// Noise channel binding (a transcript hash identical on both ends iff there was +// no MITM — use sas.FromBinding to show a safety number). +func RunInitiator(ctx context.Context, mc peer.MsgConn, token []byte, wallet *identity.Wallet) (*AgentInfo, []byte, error) { hs, err := newHandshake(true, token) if err != nil { return nil, nil, err } - msg1, _, _, err := hs.WriteMessage(nil, ownerPub) + claim, _ := json.Marshal(PairClaim{Wallet: wallet.Address}) + msg1, _, _, err := hs.WriteMessage(nil, claim) if err != nil { return nil, nil, err } @@ -62,31 +74,56 @@ func RunInitiator(ctx context.Context, mc peer.MsgConn, token, ownerPub []byte) if err := json.Unmarshal(payload, &info); err != nil { return nil, nil, err } - return &info, hs.ChannelBinding(), nil + // msg3: prove control of the wallet by signing the channel binding. The + // signature is public (it binds to the transcript hash), so it travels as a + // plain frame outside the Noise payload. + binding := hs.ChannelBinding() + if err := mc.Send(wallet.SignAuth(binding)); err != nil { + return nil, nil, err + } + return &info, binding, nil } -// RunResponder is the agent side: it returns the client's owner key plus the -// Noise channel binding (see RunInitiator). -func RunResponder(ctx context.Context, mc peer.MsgConn, token []byte, info AgentInfo) ([]byte, []byte, error) { +// RunResponder is the agent side: it reads the client's PairClaim (msg1), sends +// its info (msg2), then verifies the client's wallet auth signature over the +// channel binding (msg3). It returns the proven base58 wallet plus the Noise +// channel binding (see RunInitiator). The wallet is returned only if auth +// verifies — callers pin it directly. +func RunResponder(ctx context.Context, mc peer.MsgConn, token []byte, info AgentInfo) (string, []byte, error) { hs, err := newHandshake(false, token) if err != nil { - return nil, nil, err + return "", nil, err } msg1, err := mc.Recv(ctx) if err != nil { - return nil, nil, err + return "", nil, err } - ownerPub, _, _, err := hs.ReadMessage(nil, msg1) + payload, _, _, err := hs.ReadMessage(nil, msg1) if err != nil { - return nil, nil, fmt.Errorf("pairing handshake failed (wrong code?): %w", err) + return "", nil, fmt.Errorf("pairing handshake failed (wrong code?): %w", err) + } + var claim PairClaim + if err := json.Unmarshal(payload, &claim); err != nil { + return "", nil, fmt.Errorf("pairing: bad claim: %w", err) + } + if pk, derr := base58.Decode(claim.Wallet); derr != nil || len(pk) != 32 { + return "", nil, fmt.Errorf("pairing: bad wallet") } infoJSON, _ := json.Marshal(info) msg2, _, _, err := hs.WriteMessage(nil, infoJSON) if err != nil { - return nil, nil, err + return "", nil, err } if err := mc.Send(msg2); err != nil { - return nil, nil, err + return "", nil, err + } + binding := hs.ChannelBinding() + sig, err := mc.Recv(ctx) // msg3: the wallet auth signature over the binding + if err != nil { + return "", nil, err + } + if err := identity.VerifyAuth(claim.Wallet, binding, sig); err != nil { + return "", nil, fmt.Errorf("pairing: wallet auth failed: %w", err) } - return ownerPub, hs.ChannelBinding(), nil + return claim.Wallet, binding, nil } diff --git a/go/internal/pairing/pairing_test.go b/go/internal/pairing/pairing_test.go index 437ee32..2b15e6e 100644 --- a/go/internal/pairing/pairing_test.go +++ b/go/internal/pairing/pairing_test.go @@ -4,36 +4,57 @@ package pairing import ( "context" "encoding/hex" + "encoding/json" + "strings" "testing" "time" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/noise" "github.com/srcful/terminal-relay/go/internal/peer" "github.com/srcful/terminal-relay/go/internal/sas" ) +// testWallet mints a real prf-rooted wallet for pairing tests. The prf seed is +// derived from b so each call with a distinct byte yields a distinct wallet. +func testWallet(t *testing.T, b byte) *identity.Wallet { + t.Helper() + prf := make([]byte, 32) + for i := range prf { + prf[i] = b ^ byte(i) + } + w, err := identity.DeriveWallet(prf) + if err != nil { + t.Fatalf("DeriveWallet: %v", err) + } + return w +} + func TestPairingExchangesAndPinsKeys(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() token := NewToken() - _, ownerPub, _ := noise.GenerateStatic() // client owner key - _, hostPub, _ := noise.GenerateStatic() // agent host key + wallet := testWallet(t, 0x11) // client wallet + _, hostPub, _ := noise.GenerateStatic() // agent host key clientMC, agentMC := peer.Pipe() info := AgentInfo{HostPubHex: hex.EncodeToString(hostPub), MachineID: "m123", Name: "box"} - type respResult struct{ owner, binding []byte } + type respResult struct { + wallet string + binding []byte + } gotResp := make(chan respResult, 1) go func() { - op, b, err := RunResponder(ctx, agentMC, token, info) + wal, b, err := RunResponder(ctx, agentMC, token, info) if err != nil { return } - gotResp <- respResult{owner: op, binding: b} + gotResp <- respResult{wallet: wal, binding: b} }() - got, initBinding, err := RunInitiator(ctx, clientMC, token, ownerPub) + got, initBinding, err := RunInitiator(ctx, clientMC, token, wallet) if err != nil { t.Fatalf("initiator: %v", err) } @@ -42,15 +63,16 @@ func TestPairingExchangesAndPinsKeys(t *testing.T) { } select { case r := <-gotResp: - if hex.EncodeToString(r.owner) != hex.EncodeToString(ownerPub) { - t.Fatal("agent pinned the wrong owner key") + // The responder returns the base58 wallet from the claim, proven by auth. + if r.wallet != wallet.Address { + t.Fatalf("agent pinned the wrong wallet: got %s want %s", r.wallet, wallet.Address) } // No MITM => both ends derive the same channel binding => same safety number. if sas.FromBinding(initBinding) != sas.FromBinding(r.binding) { t.Fatal("safety numbers differ; channel bindings must match without a MITM") } case <-time.After(2 * time.Second): - t.Fatal("agent never received owner key") + t.Fatal("agent never received wallet claim") } } @@ -58,14 +80,73 @@ func TestPairingFailsWithWrongToken(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() - _, ownerPub, _ := noise.GenerateStatic() + wallet := testWallet(t, 0x22) _, hostPub, _ := noise.GenerateStatic() clientMC, agentMC := peer.Pipe() info := AgentInfo{HostPubHex: hex.EncodeToString(hostPub), MachineID: "m", Name: "n"} go func() { _, _, _ = RunResponder(ctx, agentMC, NewToken(), info) }() // different token - if _, _, err := RunInitiator(ctx, clientMC, NewToken(), ownerPub); err == nil { + if _, _, err := RunInitiator(ctx, clientMC, NewToken(), wallet); err == nil { t.Fatal("expected pairing to fail with mismatched tokens") } } + +// TestPairingRejectsBadWalletAuth proves the agent pins a wallet ONLY when the +// initiator can sign the channel binding with that wallet's key. We drive the +// Noise handshake manually so we can ship a msg3 signature over the WRONG +// challenge — exactly what a MITM (who relayed msg1's claim but cannot sign) +// would be forced to do. +func TestPairingRejectsBadWalletAuth(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + token := NewToken() + wallet := testWallet(t, 0x33) + _, hostPub, _ := noise.GenerateStatic() + clientMC, agentMC := peer.Pipe() + info := AgentInfo{HostPubHex: hex.EncodeToString(hostPub), MachineID: "m", Name: "n"} + + gotErr := make(chan error, 1) + go func() { + _, _, err := RunResponder(ctx, agentMC, token, info) + gotErr <- err + }() + + // Hand-rolled initiator that signs a DIFFERENT challenge for msg3. + hs, err := newHandshake(true, token) + if err != nil { + t.Fatal(err) + } + claim, _ := json.Marshal(PairClaim{Wallet: wallet.Address}) + msg1, _, _, err := hs.WriteMessage(nil, claim) + if err != nil { + t.Fatal(err) + } + if err := clientMC.Send(msg1); err != nil { + t.Fatal(err) + } + msg2, err := clientMC.Recv(ctx) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := hs.ReadMessage(nil, msg2); err != nil { + t.Fatal(err) + } + // Sign the wrong challenge -> auth must fail on the responder. + if err := clientMC.Send(wallet.SignAuth([]byte("not the binding"))); err != nil { + t.Fatal(err) + } + + select { + case err := <-gotErr: + if err == nil { + t.Fatal("responder accepted a wallet that did not sign the binding") + } + if !strings.Contains(err.Error(), "wallet auth failed") { + t.Fatalf("expected wallet auth failure, got: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("responder never returned") + } +} diff --git a/testdata/pair-interop.json b/testdata/pair-interop.json index 3876e59..0e525a7 100644 --- a/testdata/pair-interop.json +++ b/testdata/pair-interop.json @@ -1,10 +1,12 @@ { "token": "00112233445566778899aabbccddeeff", - "owner_pub": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "wallet": "5hoo57BiX5M1dQJF6BKtFm4iRpBAgmZGEQKeqqeJjGX5", + "claim": "{\"wallet\":\"5hoo57BiX5M1dQJF6BKtFm4iRpBAgmZGEQKeqqeJjGX5\"}", "info_json": "{\"host_pub\":\"5051525354555657585950515253545550515253545556575859505152535455\",\"machine_id\":\"m42\",\"name\":\"box\"}", "room_id": "812dd0fd5dc86afd4c6a69914a65e5ab", "psk": "964186805c72ef0d89a0425c747e3e421d5034eaf2441b33bc066ce041175a09", - "msg1": "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c42147ee717c8415750bf86c21abb4ab72dfaa0dc3db742e0ad5ca90ed1974078aaccef1f6141f76fb69c5c522addba48", - "msg2": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b8f6936500f161e71050cedf7e12066e682ebfb1c13c196ee66c7cc60cae5483361738d4243b8640ce7bf79af15f6350925435efd75175002c9ba8a82beade9bb6a2b9eb17300292187ca50c8e610d0f51afaf4272554c9e467a92ec69049cfb0a428e63386b5447260501e3c7850f94c9f42e518baaf96b7e02a8e813f0dd3", - "safety_number": "162a-f846-b7e7-5584" + "msg1": "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c9997ab25df018284da2c0e5cde798b2daa097b37bc4fc53344af55832f618a815f201f441be7e26f0fdfd31b0acf2ef29443a0c57d2c8def0268ab8073113eb4aaa21fd5fb45c16417", + "msg2": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b8f6936500f161e71050cedf7e12066e682ebfb1c13c196ee66c7cc60cae5483361738d4243b8640ce7bf79af15f6350925435efd75175002c9ba8a82beade9bb6a2b9eb17300292187ca50c8e610d0f51afaf4272554c9e467a92ec69049cfb0a428e63386b5447260501e3c7850f91462425a1fe37205e7f2f356c681fa0a", + "msg3": "0c9848e0feb2e25f26cca49319333e6c4e343b9b4d14f33900319f1a2695bfcb9369ecf1ebbd75a6ec7db075d56467e5b878a65b8942143c13e80e6cd3a65a0b", + "safety_number": "a643-3d65-682b-4298" } \ No newline at end of file From 14816e742697d8d8d74a755a26b4b20600432a3e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 13 Jun 2026 12:16:53 +0200 Subject: [PATCH 5/5] feat(web): B1.5 wallet owner_id + binding + pairing auth in browser Sign-in derives the Ed25519 wallet alongside the X25519 transport key (one prf root, mirroring Go's owner.json). Attach dials owner_id= and carries a signed binding on the offer; the Noise-KK initiator still uses the X25519 transport key. Pairing sends PairClaim{wallet} + a msg3 auth signature over the channel binding, byte-identical to Go. Adds identity/auth.js and a stable per-browser device id; precache auth.js. The pairing interop vector now carries wallet_prf so the JS side derives the same wallet and reproduces msg1/msg3 exactly. wallet-derivation and wallet-binding vectors are untouched (Noise data plane unchanged). Co-Authored-By: Claude Opus 4.8 --- go/internal/pairing/interop_test.go | 7 +++-- testdata/pair-interop.json | 1 + web/src/app.js | 37 +++++++++++++++++------ web/src/identity.js | 39 ++++++++++++++++-------- web/src/identity/auth.js | 28 +++++++++++++++++ web/src/pair.js | 9 +++--- web/src/pairing/nnpsk0.js | 24 ++++++++++----- web/sw.js | 1 + web/test/auth.test.js | 47 +++++++++++++++++++++++++++++ web/test/pair.test.js | 22 ++++++++------ web/test/pairing-interop.test.js | 19 +++++++----- 11 files changed, 181 insertions(+), 53 deletions(-) create mode 100644 web/src/identity/auth.js create mode 100644 web/test/auth.test.js diff --git a/go/internal/pairing/interop_test.go b/go/internal/pairing/interop_test.go index 15ec00c..4282877 100644 --- a/go/internal/pairing/interop_test.go +++ b/go/internal/pairing/interop_test.go @@ -42,8 +42,9 @@ func (r *fixedReader) Read(p []byte) (int, error) { type pairVectors struct { Token string `json:"token"` - Wallet string `json:"wallet"` // base58 wallet the claim carries - Claim string `json:"claim"` // msg1 payload (JSON PairClaim) before Noise framing + WalletPRF string `json:"wallet_prf"` // 32-byte prf root the wallet derives from (so JS reproduces msg3) + Wallet string `json:"wallet"` // base58 wallet the claim carries + Claim string `json:"claim"` // msg1 payload (JSON PairClaim) before Noise framing InfoJSON string `json:"info_json"` RoomID string `json:"room_id"` PSK string `json:"psk"` @@ -104,7 +105,7 @@ func runFixed(t *testing.T) pairVectors { } psk := sha256.Sum256(append([]byte("terminal-relay/pair/psk"), fxToken...)) return pairVectors{ - Token: hex.EncodeToString(fxToken), Wallet: wallet.Address, + Token: hex.EncodeToString(fxToken), WalletPRF: hex.EncodeToString(fxWalletPRF), Wallet: wallet.Address, Claim: string(claim), InfoJSON: fxInfo, RoomID: RoomID(fxToken), PSK: hex.EncodeToString(psk[:]), Msg1: hex.EncodeToString(msg1), Msg2: hex.EncodeToString(msg2), diff --git a/testdata/pair-interop.json b/testdata/pair-interop.json index 0e525a7..1c29f94 100644 --- a/testdata/pair-interop.json +++ b/testdata/pair-interop.json @@ -1,5 +1,6 @@ { "token": "00112233445566778899aabbccddeeff", + "wallet_prf": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", "wallet": "5hoo57BiX5M1dQJF6BKtFm4iRpBAgmZGEQKeqqeJjGX5", "claim": "{\"wallet\":\"5hoo57BiX5M1dQJF6BKtFm4iRpBAgmZGEQKeqqeJjGX5\"}", "info_json": "{\"host_pub\":\"5051525354555657585950515253545550515253545556575859505152535455\",\"machine_id\":\"m42\",\"name\":\"box\"}", diff --git a/web/src/app.js b/web/src/app.js index 4ff73cc..248693a 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -12,6 +12,7 @@ import { listMachines, addMachine } from './store.js'; import { pairWithCode } from './pair.js'; import { confirmPairingSafety, machineAfterConfirmedPairing, pendingPairingConfirmation } from './pairing/confirm.js'; import { registerPasskey, signInPasskey, devOwnerKey, passkeySupported, isLocalhost } from './identity.js'; +import { signBinding, recordJSON } from './identity/binding.js'; import { makeKeybar, shouldShowKeybar } from './ui/keybar.js'; import jsQR from '/vendor/jsqr.js'; @@ -38,12 +39,26 @@ async function iceServers(signalURL) { // Resolved once at the sign-in gate and cached; ownerKey() stays synchronous so // attach()/pairWithCode() are untouched (passkey get() is async + needs a user // gesture, so it can't run inside a sync call). -let _owner = null; +// _id holds the full prf-rooted identity { owner, wallet }: `owner` is the X25519 +// transport keypair (Noise-KK static, unchanged) and `wallet` is the Ed25519 Solana +// wallet ({ address, priv, ... }) that now serves as owner_id on the wire. +let _id = null; export function ownerKey() { - if (!_owner) throw new Error('not signed in'); - return _owner; + if (!_id) throw new Error('not signed in'); + return _id.owner; +} +export function walletKey() { + if (!_id) throw new Error('not signed in'); + return _id.wallet; +} +function setOwner(id) { _id = id; window.__ownerPub = bytesToHex(id.owner.pub); window.__wallet = id.wallet.address; } + +// deviceID returns a stable per-browser device id (binding.device), generated once. +function deviceID() { + let d = localStorage.getItem('tr_device_id'); + if (!d) { d = bytesToHex(crypto.getRandomValues(new Uint8Array(8))); localStorage.setItem('tr_device_id', d); } + return d; } -function setOwner(k) { _owner = k; window.__ownerPub = bytesToHex(k.pub); } const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss @@ -58,12 +73,16 @@ const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss // tmux FrameWindows snapshot. The caller (makeTerminal) owns the terminal + teardown. export async function connectOnce(machine, term, current, onConnected, onWindows) { const owner = ownerKey(); - const ownerHex = bytesToHex(owner.pub); + const wallet = walletKey(); + // owner_id is the base58 wallet address; the agent recovers the X25519 KK pin from + // the signed binding carried on the offer (it cannot hex-decode the wallet). + const ownerId = wallet.address; + const binding = recordJSON(signBinding(wallet, deviceID(), bytesToHex(owner.pub), Math.floor(Date.now() / 1000))); const diag = { step: 'start', ws: 'init', gather: '', iceConn: '', conn: '', dc: 'init' }; window.__diag = diag; const ws = new WebSocket( - wsBase(machine.signal) + '/attach?owner_id=' + encodeURIComponent(ownerHex) + + wsBase(machine.signal) + '/attach?owner_id=' + encodeURIComponent(ownerId) + '&machine_id=' + encodeURIComponent(machine.machine_id), ); // `aborted` lets the caller (handle.close on Back/switch) tear down a LIVE session: @@ -128,7 +147,7 @@ export async function connectOnce(machine, term, current, onConnected, onWindows pc.addEventListener('icegatheringstatechange', () => { diag.gather = pc.iceGatheringState; if (pc.iceGatheringState === 'complete') finish(); }); }); diag.step = 'offer-sent'; - ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription.sdp })); + ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription.sdp, binding })); diag.step = 'awaiting-datachannel'; const inbox = []; @@ -340,7 +359,7 @@ function viewPair(root, prefill = '', auto = false) { mount(root, el('div', { className: 'view' }, el('h1', {}, 'pairing…'), status)); status.textContent = 'pairing…'; try { - const { machine, safetyNumber } = await pairWithCode(code, ownerKey().pub); + const { machine, safetyNumber } = await pairWithCode(code, walletKey()); const pending = pendingPairingConfirmation(machine, safetyNumber); window.__lastSafety = safetyNumber; status.innerHTML = ''; @@ -667,5 +686,5 @@ export function start(root) { window.__useDevKey = () => { setOwner(devOwnerKey()); localStorage.setItem('tr_identity_mode', 'dev'); viewMachines(root); }; } window.trAttach = (m) => attach(m, root.querySelector('.termbox') || root); - window.trPair = (code) => pairWithCode(code, ownerKey().pub); + window.trPair = (code) => pairWithCode(code, walletKey()); } diff --git a/web/src/identity.js b/web/src/identity.js index 624476a..3763705 100644 --- a/web/src/identity.js +++ b/web/src/identity.js @@ -1,13 +1,23 @@ // web/src/identity.js — owner identity from a passkey (WebAuthn prf), with a // degraded dev fallback. The prf output (deterministic per credential+salt, and -// the same on every device the synced passkey reaches) is fed UNCHANGED into the -// existing deriveOwnerKey() so the owner_id follows you across devices and is +// the same on every device the synced passkey reaches) is fed UNCHANGED into +// BOTH deriveOwnerKey() (X25519 transport key) and deriveWallet() (the Ed25519 +// Solana wallet that anchors ownership). They share only the prf root, so the +// owner_id (wallet address) and transport key follow you across devices and are // gated by Face ID / Touch ID. The relay never sees any of this. import { deriveOwnerKey } from './identity/owner.js'; +import { deriveWallet } from './identity/wallet.js'; import { resolveRPID } from './rp.js'; -import { x25519 } from '@noble/curves/ed25519'; +import { randomBytes } from '@noble/hashes/utils'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +// identityFromPRF derives the full identity ({ owner, wallet }) rooted in one +// 32-byte secret — the passkey prf output, or the dev secret. Mirrors how the Go +// owner.json roots both keys in a single seed. +function identityFromPRF(secret) { + return { owner: deriveOwnerKey(secret), wallet: deriveWallet(secret) }; +} + const enc = new TextEncoder(); // rp.id is scoped to the exact production app host. Localhost remains separate // for dev. Do not use the parent domain as the RP trust root. @@ -72,21 +82,26 @@ export async function signInPasskey() { const prf = assertion.getClientExtensionResults().prf?.results?.first; if (!prf) throw new Error('NO_PRF'); localStorage.setItem('tr_cred_id', b64url(assertion.rawId)); - return deriveOwnerKey(new Uint8Array(prf)); + return identityFromPRF(new Uint8Array(prf)); } -// devOwnerKey is the DEGRADED localhost-only fallback: a plaintext x25519 key in -// localStorage (not biometric-gated, not synced). It is hard-guarded to localhost -// so a real owner private key can NEVER be minted/persisted in the clear on a -// production origin (where any same-origin script could read it) — not even when -// the browser lacks WebAuthn. On a public host the passkey path is the only way in. +// devOwnerKey is the DEGRADED localhost-only fallback: a plaintext 32-byte secret +// in localStorage (not biometric-gated, not synced) that roots BOTH the X25519 +// transport key and the Ed25519 wallet — exactly as the prf output does on the +// real path, mirroring Go's owner.json single-seed model. It is hard-guarded to +// localhost so a real owner identity can NEVER be minted/persisted in the clear +// on a production origin (where any same-origin script could read it) — not even +// when the browser lacks WebAuthn. On a public host the passkey path is the only +// way in. Returns { owner, wallet }. export function devOwnerKey() { if (!isLocalhost()) throw new Error('dev key is localhost-only; use a passkey on a public origin'); let h = localStorage.getItem('tr_owner'); if (!h) { - h = bytesToHex(x25519.utils.randomPrivateKey()); + // 32-byte secret seed: the BIP39/wallet derivation needs 16..32 bytes. + h = bytesToHex(randomBytes(32)); localStorage.setItem('tr_owner', h); } - const priv = hexToBytes(h); - return { priv, pub: x25519.getPublicKey(priv) }; + // Migration: a pre-B1.5 tr_owner held a raw 32-byte x25519 priv. Reuse it as the + // secret seed so an existing dev install keeps a stable (if re-rooted) identity. + return identityFromPRF(hexToBytes(h)); } diff --git a/web/src/identity/auth.js b/web/src/identity/auth.js new file mode 100644 index 0000000..379ae51 --- /dev/null +++ b/web/src/identity/auth.js @@ -0,0 +1,28 @@ +// web/src/identity/auth.js — mirrors go/internal/identity/auth.go. Wallet control +// proof over a fresh challenge (e.g. a pairing channel binding). +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; +} + +// signAuth returns the raw 64-byte Ed25519 signature over AUTH_DOMAIN||challenge. +export function signAuth(wallet, challenge) { + return ed25519.sign(authMessage(challenge), wallet.priv); +} + +// verifyAuth checks a signAuth signature against a base58 wallet address. +export function verifyAuth(walletAddress, challenge, sig) { + let pub; + try { pub = b58decode(walletAddress); } catch { return false; } + if (pub.length !== 32 || sig.length !== 64) return false; + return ed25519.verify(sig, authMessage(challenge), pub); +} diff --git a/web/src/pair.js b/web/src/pair.js index a88a787..86a1e88 100644 --- a/web/src/pair.js +++ b/web/src/pair.js @@ -12,9 +12,10 @@ const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss // "pairing…" forever. 30s is generous for a human-paced QR scan + two round trips. const PAIR_TIMEOUT_MS = 30000; -// pairWithCode runs the pairing handshake using `ownerPub` as our identity. -// Returns { machine, safetyNumber }. -export async function pairWithCode(code, ownerPub) { +// pairWithCode runs the pairing handshake using `wallet` ({ address, priv }) as our +// identity: it sends the base58 wallet as a PairClaim and proves control with an auth +// signature over the channel binding. Returns { machine, safetyNumber }. +export async function pairWithCode(code, wallet) { const { signalURL, token } = decodeCode(code); const ws = new WebSocket(wsBase(signalURL) + '/pair?room=' + roomID(token)); @@ -62,7 +63,7 @@ export async function pairWithCode(code, ownerPub) { }), }; - const { info, binding } = await runInitiator(mc, token, ownerPub); + const { info, binding } = await runInitiator(mc, token, wallet); return { machine: { machine_id: info.machine_id, host_pub: info.host_pub, name: info.name, signal: signalURL }, safetyNumber: safetyNumber(binding), diff --git a/web/src/pairing/nnpsk0.js b/web/src/pairing/nnpsk0.js index 2de23e4..20d482c 100644 --- a/web/src/pairing/nnpsk0.js +++ b/web/src/pairing/nnpsk0.js @@ -13,6 +13,7 @@ import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { sha256 } from '@noble/hashes/sha2'; import { hmac } from '@noble/hashes/hmac'; import { pskFromToken } from './code.js'; +import { signAuth, verifyAuth } from '../identity/auth.js'; const PROTOCOL_NAME = 'Noise_NNpsk0_25519_ChaChaPoly_SHA256'; const PROLOGUE = new TextEncoder().encode('terminal-relay/pair/v1'); @@ -225,26 +226,33 @@ export class HandshakeNNpsk0 { } } -// runInitiator (client): sends ownerPub in msg1, reads agent info from msg2. +// runInitiator (client): sends PairClaim{wallet} in msg1, reads agent info from +// msg2, then proves wallet control with msg3 = signAuth(channelBinding). // Returns { info, binding }. `ephemeralPriv` is optional (deterministic tests). -export async function runInitiator(mc, token, ownerPub, ephemeralPriv = null) { +export async function runInitiator(mc, token, wallet, ephemeralPriv = null) { const hs = new HandshakeNNpsk0({ initiator: true, psk: pskFromToken(token), ephemeralPriv }); - const msg1 = hs.writeMessage(ownerPub); + const msg1 = hs.writeMessage(new TextEncoder().encode(JSON.stringify({ wallet: wallet.address }))); await mc.send(msg1); const msg2 = await mc.recv(); const payload = hs.readMessage(msg2); const info = JSON.parse(new TextDecoder().decode(payload)); - return { info, binding: hs.binding() }; + const binding = hs.binding(); + await mc.send(signAuth(wallet, binding)); // msg3, raw 64-byte sig + return { info, binding }; } -// runResponder (agent): reads ownerPub from msg1, sends info in msg2. -// Returns { ownerPub, binding }. `ephemeralPriv` is optional (deterministic tests). +// runResponder (agent): reads PairClaim{wallet} from msg1, sends info in msg2, +// then verifies msg3 (wallet auth over the channel binding). Returns +// { wallet, binding }. `ephemeralPriv` is optional (deterministic tests). export async function runResponder(mc, token, info, ephemeralPriv = null) { const hs = new HandshakeNNpsk0({ initiator: false, psk: pskFromToken(token), ephemeralPriv }); const msg1 = await mc.recv(); - const ownerPub = hs.readMessage(msg1); + const claim = JSON.parse(new TextDecoder().decode(hs.readMessage(msg1))); const infoJSON = new TextEncoder().encode(JSON.stringify(info)); const msg2 = hs.writeMessage(infoJSON); await mc.send(msg2); - return { ownerPub, binding: hs.binding() }; + const binding = hs.binding(); + const sig = await mc.recv(); // msg3 + if (!verifyAuth(claim.wallet, binding, sig)) throw new Error('pairing: wallet auth failed'); + return { wallet: claim.wallet, binding }; } diff --git a/web/sw.js b/web/sw.js index f346d3d..1ce79ef 100644 --- a/web/sw.js +++ b/web/sw.js @@ -27,6 +27,7 @@ const SHELL = [ '/src/app.js', '/src/boot.js', '/src/identity.js', + '/src/identity/auth.js', '/src/identity/binding.js', '/src/identity/owner.js', '/src/identity/wallet.js', diff --git a/web/test/auth.test.js b/web/test/auth.test.js new file mode 100644 index 0000000..47998c1 --- /dev/null +++ b/web/test/auth.test.js @@ -0,0 +1,47 @@ +// web/test/auth.test.js — round-trip + tamper tests for the wallet auth proof +// (web/src/identity/auth.js), mirroring go/internal/identity/auth_test.go. The +// cross-impl byte check against the pairing vector (msg3) lives in the interop +// test, where the channel binding the signature commits to is available. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hexToBytes } from '@noble/hashes/utils'; +import { deriveWallet } from '../src/identity/wallet.js'; +import { signAuth, verifyAuth } from '../src/identity/auth.js'; + +const wallet = deriveWallet(hexToBytes('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff')); + +test('signAuth round-trips through verifyAuth', () => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const sig = signAuth(wallet, challenge); + assert.equal(sig.length, 64); + assert.equal(verifyAuth(wallet.address, challenge, sig), true); +}); + +test('a tampered challenge does not verify', () => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const sig = signAuth(wallet, challenge); + const bad = challenge.slice(); + bad[0] ^= 0xff; + assert.equal(verifyAuth(wallet.address, bad, sig), false); +}); + +test('a tampered signature does not verify', () => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const sig = signAuth(wallet, challenge); + const bad = sig.slice(); + bad[0] ^= 0xff; + assert.equal(verifyAuth(wallet.address, challenge, bad), false); +}); + +test('the wrong wallet does not verify', () => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const sig = signAuth(wallet, challenge); + const other = deriveWallet(hexToBytes('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')); + assert.equal(verifyAuth(other.address, challenge, sig), false); +}); + +test('a bad wallet address or wrong-length sig never throws, returns false', () => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + assert.equal(verifyAuth('not-base58-0OIl', challenge, new Uint8Array(64)), false); + assert.equal(verifyAuth(wallet.address, challenge, new Uint8Array(10)), false); +}); diff --git a/web/test/pair.test.js b/web/test/pair.test.js index 6521583..cc1c571 100644 --- a/web/test/pair.test.js +++ b/web/test/pair.test.js @@ -8,6 +8,7 @@ import { hexToBytes } from '@noble/hashes/utils'; import { encodeCode } from '../src/pairing/code.js'; import { runResponder } from '../src/pairing/nnpsk0.js'; import { safetyNumber } from '../src/pairing/sas.js'; +import { deriveWallet } from '../src/identity/wallet.js'; // pairWithCode is the browser pairing entry point. These tests pin its FAILURE // handling — the bug being fixed: with no recv timeout and close/error wired only @@ -15,10 +16,11 @@ import { safetyNumber } from '../src/pairing/sas.js'; // "pairing…" forever. We drive it with a fake WebSocket and node's mock timers. // // We import pair.js lazily (after installing the fake WebSocket on globalThis) so the -// module under test constructs our fake. ownerPub is unused on the failure paths -// (runInitiator's first await is a recv()), so a 32-byte zero key is fine. +// module under test constructs our fake. On the failure paths the wallet is only +// used to build msg1 (runInitiator's first await is a recv()), so any valid wallet +// is fine; the happy path derives the wallet from the Go vector's prf root. -const ownerPub = new Uint8Array(32); +const wallet = deriveWallet(new Uint8Array(32)); // A well-formed code: 16-byte (32-hex) token + an https relay URL passes decodeCode. const CODE = encodeCode('https://relay.example.test', new Uint8Array(16).fill(7)); @@ -56,7 +58,7 @@ test('rejects (does not hang) when the relay opens but never sends — timeout f const ws = installFakeWS(); mock.timers.enable({ apis: ['setTimeout'] }); try { - const p = pairWithCode(CODE, ownerPub); + const p = pairWithCode(CODE, wallet); const settled = p.then(() => 'resolved', (e) => e); // capture without throwing yet await Promise.resolve(); ws.created[0].fireOpen(); // socket opens, runInitiator sends msg1 and awaits recv() @@ -76,7 +78,7 @@ test('rejects (does not hang) when the relay opens but never sends — timeout f test('rejects when the relay closes mid-handshake (post-open close wired)', async () => { const ws = installFakeWS(); try { - const p = pairWithCode(CODE, ownerPub); + const p = pairWithCode(CODE, wallet); const settled = p.then(() => 'resolved', (e) => e); await Promise.resolve(); ws.created[0].fireOpen(); @@ -94,7 +96,7 @@ test('rejects when the relay closes mid-handshake (post-open close wired)', asyn test('rejects when the relay errors mid-handshake (post-open error wired)', async () => { const ws = installFakeWS(); try { - const p = pairWithCode(CODE, ownerPub); + const p = pairWithCode(CODE, wallet); const settled = p.then(() => 'resolved', (e) => e); await Promise.resolve(); ws.created[0].fireOpen(); @@ -112,7 +114,7 @@ test('rejects when the relay errors mid-handshake (post-open error wired)', asyn test('rejects when the relay is unreachable (pre-open error)', async () => { const ws = installFakeWS(); try { - const p = pairWithCode(CODE, ownerPub); + const p = pairWithCode(CODE, wallet); const settled = p.then(() => 'resolved', (e) => e); await Promise.resolve(); ws.created[0].fireError(); // never opened @@ -127,7 +129,7 @@ test('rejects when the relay is unreachable (pre-open error)', async () => { test('rejects when the relay closes before opening (pre-open close)', async () => { const ws = installFakeWS(); try { - const p = pairWithCode(CODE, ownerPub); + const p = pairWithCode(CODE, wallet); const settled = p.then(() => 'resolved', (e) => e); await Promise.resolve(); ws.created[0].fireClose(); // closed before open @@ -150,7 +152,7 @@ test('completes the handshake and returns the machine + safety number', async () const ws = installFakeWS(); try { const token = hexToBytes(vec.token); - const owner = hexToBytes(vec.owner_pub); + const vecWallet = deriveWallet(hexToBytes(vec.wallet_prf)); const goodCode = encodeCode('https://relay.example.test', token); const info = JSON.parse(vec.info_json); @@ -172,7 +174,7 @@ test('completes the handshake and returns the machine + safety number', async () // agree on the binding and that the SAS is well-formed. const responderP = runResponder(responderMC, token, info, hexToBytes('2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40')); - const p = pairWithCode(goodCode, owner); + const p = pairWithCode(goodCode, vecWallet); await Promise.resolve(); sock[0].fireOpen(); const result = await p; diff --git a/web/test/pairing-interop.test.js b/web/test/pairing-interop.test.js index 26840fa..d62cd89 100644 --- a/web/test/pairing-interop.test.js +++ b/web/test/pairing-interop.test.js @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { runInitiator, runResponder } from '../src/pairing/nnpsk0.js'; import { safetyNumber } from '../src/pairing/sas.js'; +import { deriveWallet } from '../src/identity/wallet.js'; const here = dirname(fileURLToPath(import.meta.url)); const v = JSON.parse(readFileSync(join(here, '..', '..', 'testdata', 'pair-interop.json'), 'utf8')); @@ -32,24 +33,28 @@ function pipe() { return [mk(a2b, b2a, 'a2b'), mk(b2a, a2b, 'b2a'), sent]; } -test('JS NNpsk0 reproduces the Go pairing wire bytes + safety number', async () => { +test('JS NNpsk0 reproduces the Go pairing wire bytes + wallet auth + safety number', async () => { const [clientMC, agentMC, sent] = pipe(); const token = hexToBytes(v.token); - const ownerPub = hexToBytes(v.owner_pub); + // The wallet is derived from the SAME prf root the Go vector used, so the JS + // side reproduces msg1 (PairClaim) and msg3 (the Ed25519 auth signature, which + // is deterministic) byte-for-byte. + const wallet = deriveWallet(hexToBytes(v.wallet_prf)); const info = JSON.parse(v.info_json); // fixed ephemerals so the bytes are deterministic (match the Go vectors) const agentP = runResponder(agentMC, token, info, hexToBytes('2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40')); - const client = await runInitiator(clientMC, token, ownerPub, hexToBytes('0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20')); + const client = await runInitiator(clientMC, token, wallet, hexToBytes('0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20')); const agent = await agentP; // The exact wire bytes the client/agent put on the pipe must equal the Go vectors. - assert.equal(bytesToHex(sent.a2b[0]), v.msg1, 'msg1 (client->agent) must match Go'); - assert.equal(bytesToHex(sent.b2a[0]), v.msg2, 'msg2 (agent->client) must match Go'); + assert.equal(bytesToHex(sent.a2b[0]), v.msg1, 'msg1 PairClaim (client->agent) must match Go'); + assert.equal(bytesToHex(sent.b2a[0]), v.msg2, 'msg2 AgentInfo (agent->client) must match Go'); + assert.equal(bytesToHex(sent.a2b[1]), v.msg3, 'msg3 wallet auth (client->agent) must match Go'); - // Decrypted payloads + channel bindings (safety number) must match Go too. + // The agent recovers the proven wallet; payloads + channel bindings match Go too. + assert.equal(agent.wallet, v.wallet); assert.equal(client.info.host_pub, info.host_pub); - assert.equal(bytesToHex(agent.ownerPub), v.owner_pub); assert.equal(safetyNumber(client.binding), v.safety_number); assert.equal(safetyNumber(agent.binding), v.safety_number); });