package contratest import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "net/http" "strings" "testing" "time" "github.com/carlosframework/contra/internal/contra/crypto" "github.com/carlosframework/contra/internal/contra/store" ) // The device handoff, end to end over real HTTP. // // This is the shape of test that found the wrap-id bug: a client that seals // against ids it minted, a server that stores what it is given, and a second // client that has to open what comes back. Anything that quietly rewrites an // identifier in the middle breaks the AAD and shows up here rather than in // production six weeks later. // waitingDevice is the fresh browser's half: an ephemeral keypair whose // scalar never leaves it, and a code it minted itself. type waitingDevice struct { scalar []byte publicKey []byte code string hashHex string } func newWaitingDevice(t *testing.T) waitingDevice { t.Helper() seed := make([]byte, 32) if _, err := rand.Read(seed); err != nil { t.Fatal(err) } key, err := crypto.DeriveP256(seed) if err != nil { t.Fatal(err) } // Eight Crockford symbols, as link.js draws them. const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" raw := make([]byte, 8) if _, err := rand.Read(raw); err != nil { t.Fatal(err) } code := "" for _, b := range raw { code += string(alphabet[b&31]) } sum := sha256.Sum256([]byte(code)) return waitingDevice{ scalar: key.Bytes(), publicKey: key.PublicKey().Bytes(), code: code, hashHex: hex.EncodeToString(sum[:]), } } func (d waitingDevice) start(t *testing.T, h http.Handler, cookie string) { t.Helper() rec := as(t, h, cookie, http.MethodPost, "/api/link/start", map[string]string{ "code_hash_hex": d.hashHex, "device_public_hex": hex.EncodeToString(d.publicKey), }) if rec.Code != http.StatusOK { t.Fatalf("link start: status %d, body %s", rec.Code, rec.Body.String()) } } // approve is the enrolled device's half: look the code up, seal the Account // Key to the point that comes back, post the envelope. func (d waitingDevice) approve(t *testing.T, h http.Handler, cookie string, accountID, accountKey []byte) { t.Helper() var pending struct { DevicePublicHex string `json:"device_public_hex"` Approved bool `json:"approved"` } rec := as(t, h, cookie, http.MethodGet, "/api/link/pending?h="+d.hashHex, nil) if rec.Code != http.StatusOK { t.Fatalf("link pending: status %d, body %s", rec.Code, rec.Body.String()) } decode(t, rec, &pending) devicePublic, err := hex.DecodeString(pending.DevicePublicHex) if err != nil { t.Fatalf("device key hex: %v", err) } envelope, err := crypto.SealTo(devicePublic, crypto.AAD(accountID, crypto.ObjectHandoff, devicePublic, 1), accountKey) if err != nil { t.Fatalf("SealTo: %v", err) } rec = as(t, h, cookie, http.MethodPost, "/api/link/approve", map[string]string{ "code_hash_hex": d.hashHex, "sealed_hex": hex.EncodeToString(envelope), }) if rec.Code != http.StatusNoContent { t.Fatalf("link approve: status %d, body %s", rec.Code, rec.Body.String()) } } func (d waitingDevice) claim(t *testing.T, h http.Handler, cookie string) (bool, []byte) { t.Helper() var res struct { Approved bool `json:"approved"` SealedHex string `json:"sealed_hex"` } rec := as(t, h, cookie, http.MethodGet, "/api/link/claim?h="+d.hashHex, nil) if rec.Code != http.StatusOK { t.Fatalf("link claim: status %d, body %s", rec.Code, rec.Body.String()) } decode(t, rec, &res) if !res.Approved { return false, nil } sealed, err := hex.DecodeString(res.SealedHex) if err != nil { t.Fatalf("sealed hex: %v", err) } return true, sealed } func TestHandoffCarriesTheAccountKey(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) // Nothing to collect before an enrolled device approves, and that is a // 200 rather than an error: "keep waiting" is not a failure. if approved, _ := device.claim(t, h, p.cookie); approved { t.Fatal("a link was claimable before it was approved") } device.approve(t, h, p.cookie, p.id, p.accountKey) approved, envelope := device.claim(t, h, p.cookie) if !approved { t.Fatal("an approved link did not come back approved") } got, err := crypto.OpenFrom(device.scalar, device.publicKey, crypto.AAD(p.id, crypto.ObjectHandoff, device.publicKey, 1), envelope) if err != nil { t.Fatalf("the waiting device cannot open what the server relayed: %v", err) } if hex.EncodeToString(got) != hex.EncodeToString(p.accountKey) { t.Errorf("handoff delivered the wrong key\n got %x\n want %x", got, p.accountKey) } } // The envelope leaves exactly once. A second tab, or a reloaded page, must // get nothing — otherwise a code that was on screen for ten minutes stays // spendable for as long as anyone kept the tab open. func TestHandoffIsSingleUse(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) device.approve(t, h, p.cookie, p.id, p.accountKey) if approved, _ := device.claim(t, h, p.cookie); !approved { t.Fatal("first claim did not deliver") } rec := as(t, h, p.cookie, http.MethodGet, "/api/link/claim?h="+device.hashHex, nil) if rec.Code != http.StatusNotFound { t.Errorf("second claim: status %d, want 404 — the envelope was handed out twice", rec.Code) } } // A live code cannot be spent twice on the approving side either. Without // this, a stranger who somehow learned a code could overwrite the real // envelope with one the waiting device can never open, and the handoff would // die silently. func TestHandoffApprovalIsSingleUse(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) device.approve(t, h, p.cookie, p.id, p.accountKey) rec := as(t, h, p.cookie, http.MethodPost, "/api/link/approve", map[string]string{ "code_hash_hex": device.hashHex, "sealed_hex": hex.EncodeToString(make([]byte, 128)), }) if rec.Code != http.StatusNotFound { t.Errorf("second approval: status %d, want 404", rec.Code) } // And the original still opens, which is the thing that would actually // have been lost. approved, envelope := device.claim(t, h, p.cookie) if !approved { t.Fatal("the link stopped being claimable") } got, err := crypto.OpenFrom(device.scalar, device.publicKey, crypto.AAD(p.id, crypto.ObjectHandoff, device.publicKey, 1), envelope) if err != nil || hex.EncodeToString(got) != hex.EncodeToString(p.accountKey) { t.Errorf("a second approval clobbered the real envelope: %v", err) } } // Every link route is scoped to the session's account. This is the test that // would catch that scope being dropped in a refactor — which would turn a // forty-bit code into the only thing between a stranger and an Account Key. func TestHandoffIsInvisibleToAnotherAccount(t *testing.T) { h := newApp(t) mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") device := newWaitingDevice(t) device.start(t, h, mine.cookie) for _, c := range []struct { what string method string path string body any }{ {"pending", http.MethodGet, "/api/link/pending?h=" + device.hashHex, nil}, {"claim", http.MethodGet, "/api/link/claim?h=" + device.hashHex, nil}, {"approve", http.MethodPost, "/api/link/approve", map[string]string{ "code_hash_hex": device.hashHex, "sealed_hex": hex.EncodeToString(make([]byte, 128)), }}, } { rec := as(t, h, theirs.cookie, c.method, c.path, c.body) if rec.Code != http.StatusNotFound { t.Errorf("%s as another account: status %d, want 404", c.what, rec.Code) } } // And it is still there for the account that owns it. if rec := as(t, h, mine.cookie, http.MethodGet, "/api/link/pending?h="+device.hashHex, nil); rec.Code != http.StatusOK { t.Errorf("the owner lost their own link: status %d", rec.Code) } } // The whole point of binding the recipient's key into the AAD: an envelope // relayed to a different device does not open, even by a server that stored // it and chose who to hand it to. func TestHandoffEnvelopeIsBoundToOneDevice(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) device.approve(t, h, p.cookie, p.id, p.accountKey) _, envelope := device.claim(t, h, p.cookie) other := newWaitingDevice(t) if _, err := crypto.OpenFrom(other.scalar, other.publicKey, crypto.AAD(p.id, crypto.ObjectHandoff, other.publicKey, 1), envelope); err == nil { t.Error("an envelope opened on a device it was not sealed to") } } // An account cannot leave unbounded codes lying around. Without a cap, a // stolen session could open codes until one collided with something a person // was about to approve. func TestHandoffCapsLiveLinks(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") for i := 0; i < 5; i++ { newWaitingDevice(t).start(t, h, p.cookie) } extra := newWaitingDevice(t) rec := as(t, h, p.cookie, http.MethodPost, "/api/link/start", map[string]string{ "code_hash_hex": extra.hashHex, "device_public_hex": hex.EncodeToString(extra.publicKey), }) if rec.Code != http.StatusTooManyRequests { t.Errorf("sixth live link: status %d, want 429", rec.Code) } } // A refused device key is worth its own test: a server that accepted anything // 65 bytes long would let a caller store a point that is not on the curve, // and the failure would surface as an unopenable envelope much later. func TestHandoffRefusesAMalformedDeviceKey(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") d := newWaitingDevice(t) for _, c := range []struct { what string key string }{ {"too short", hex.EncodeToString(make([]byte, 33))}, {"not hex", "nonsense"}, } { rec := as(t, h, p.cookie, http.MethodPost, "/api/link/start", map[string]string{ "code_hash_hex": d.hashHex, "device_public_hex": c.key, }) if rec.Code != http.StatusBadRequest { t.Errorf("%s device key: status %d, want 400", c.what, rec.Code) } } } // Cancelling is what a closed tab should have done. A code left live for ten // minutes after its device is gone is a code somebody can still be talked // into approving. func TestHandoffCancelRemovesTheCode(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) if rec := as(t, h, p.cookie, http.MethodPost, "/api/link/cancel", map[string]string{"code_hash_hex": device.hashHex}); rec.Code != http.StatusNoContent { t.Fatalf("cancel: status %d", rec.Code) } if rec := as(t, h, p.cookie, http.MethodGet, "/api/link/pending?h="+device.hashHex, nil); rec.Code != http.StatusNotFound { t.Errorf("after cancel: status %d, want 404", rec.Code) } } // The sweeper. An expired link is already inert — every query carries the // expiry — so what this protects is not correctness but the table not // becoming a log of when each account enrolled a device. // // The clock is passed in rather than waited for, so this is deterministic // and takes no time. func TestSweepRemovesExpiredLinks(t *testing.T) { h, gdb := newAppWithDB(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) s := store.New(gdb) ctx := context.Background() // Nothing to do while it is live. if n, err := s.SweepLinks(ctx, time.Now().UTC()); err != nil || n != 0 { t.Fatalf("swept %d live links (err %v), want 0", n, err) } // One TTL later, it goes. later := time.Now().UTC().Add(store.LinkTTL + time.Minute) n, err := s.SweepLinks(ctx, later) if err != nil { t.Fatalf("SweepLinks: %v", err) } if n != 1 { t.Errorf("swept %d expired links, want 1", n) } if n, _ := s.SweepLinks(ctx, later); n != 0 { t.Errorf("a second sweep removed %d rows, want 0", n) } } // An expired code is refused even before the sweeper reaches it, because the // expiry is in the query. That ordering matters: if it were only the sweeper, // a code would stay live for up to five minutes past its deadline. func TestExpiredLinkIsRefusedBeforeItIsSwept(t *testing.T) { h, gdb := newAppWithDB(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") device := newWaitingDevice(t) device.start(t, h, p.cookie) s := store.New(gdb) later := time.Now().UTC().Add(store.LinkTTL + time.Minute) codeHash, err := hex.DecodeString(device.hashHex) if err != nil { t.Fatal(err) } if _, err := s.PendingLink(context.Background(), p.id, codeHash, later); err == nil { t.Error("an expired link was still resolvable") } } // The ladder's one rule: no screen dead-ends. This asserts the page really // does offer every rung and a way back, because "we built three rungs" and // "all three are on the screen" are different facts, and only the second is // what a person stranded on a new laptop needs. func TestDeviceLadderOffersEveryRung(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") body := as(t, h, p.cookie, http.MethodGet, "/device", nil).Body.String() for _, want := range []string{ `data-rung="handoff"`, // from another signed-in device `data-rung="scan"`, // from the card's QR, where the browser can `data-rung="type"`, // typed off the card, which always works `href="/vault"`, // and a way back } { if !strings.Contains(body, want) { t.Errorf("the device page is missing %s — a rung, or the way out", want) } } } // A signed-out browser must not be able to open a handoff, or the code stops // being a confirmation and becomes the only thing in the way. func TestLinkRoutesRefuseAStranger(t *testing.T) { h := newApp(t) d := newWaitingDevice(t) for _, c := range []struct { method string path string }{ {http.MethodPost, "/api/link/start"}, {http.MethodGet, "/api/link/pending?h=" + d.hashHex}, {http.MethodGet, "/api/link/claim?h=" + d.hashHex}, {http.MethodPost, "/api/link/approve"}, {http.MethodGet, "/device"}, } { rec := as(t, h, "", c.method, c.path, map[string]string{}) if rec.Code == http.StatusOK || rec.Code == http.StatusNoContent { t.Errorf("%s %s served a stranger: status %d", c.method, c.path, rec.Code) } } }