Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions go/internal/agent/binding_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 7 additions & 6 deletions go/internal/agent/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package agent
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"net/http/httptest"
"net/url"
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
23 changes: 22 additions & 1 deletion go/internal/agent/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
11 changes: 6 additions & 5 deletions go/internal/agent/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package agent
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"net/http/httptest"
"net/url"
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
14 changes: 8 additions & 6 deletions go/internal/cli/pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cli
import (
"bufio"
"context"
"encoding/hex"
"flag"
"fmt"
"io"
Expand Down Expand Up @@ -143,14 +142,18 @@ 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))
if err != nil {
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
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
9 changes: 6 additions & 3 deletions go/internal/client/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading