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) } 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/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 } 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..4282877 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,15 @@ func (r *fixedReader) Read(p []byte) (int, error) { type pairVectors struct { Token string `json:"token"` - OwnerPub string `json:"owner_pub"` + 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"` - 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 +71,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 +96,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), 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), - SafetyNum: sas.FromBinding(ini.ChannelBinding()), + Msg3: hex.EncodeToString(msg3), + SafetyNum: sas.FromBinding(binding), } } @@ -108,7 +132,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/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() diff --git a/testdata/pair-interop.json b/testdata/pair-interop.json index 3876e59..1c29f94 100644 --- a/testdata/pair-interop.json +++ b/testdata/pair-interop.json @@ -1,10 +1,13 @@ { "token": "00112233445566778899aabbccddeeff", - "owner_pub": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "wallet_prf": "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 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); });