| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/contra/internal/contra/crypto" |
| 14 | "github.com/carlosframework/contra/internal/contra/store" |
| 15 | ) |
| 16 | |
| 17 | // The device handoff, end to end over real HTTP. |
| 18 | // |
| 19 | // This is the shape of test that found the wrap-id bug: a client that seals |
| 20 | // against ids it minted, a server that stores what it is given, and a second |
| 21 | // client that has to open what comes back. Anything that quietly rewrites an |
| 22 | // identifier in the middle breaks the AAD and shows up here rather than in |
| 23 | // production six weeks later. |
| 24 | |
| 25 | // waitingDevice is the fresh browser's half: an ephemeral keypair whose |
| 26 | // scalar never leaves it, and a code it minted itself. |
| 27 | type waitingDevice struct { |
| 28 | scalar []byte |
| 29 | publicKey []byte |
| 30 | code string |
| 31 | hashHex string |
| 32 | } |
| 33 | |
| 34 | func newWaitingDevice(t *testing.T) waitingDevice { |
| 35 | t.Helper() |
| 36 | seed := make([]byte, 32) |
| 37 | if _, err := rand.Read(seed); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | key, err := crypto.DeriveP256(seed) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | // Eight Crockford symbols, as link.js draws them. |
| 45 | const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" |
| 46 | raw := make([]byte, 8) |
| 47 | if _, err := rand.Read(raw); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | code := "" |
| 51 | for _, b := range raw { |
| 52 | code += string(alphabet[b&31]) |
| 53 | } |
| 54 | sum := sha256.Sum256([]byte(code)) |
| 55 | return waitingDevice{ |
| 56 | scalar: key.Bytes(), |
| 57 | publicKey: key.PublicKey().Bytes(), |
| 58 | code: code, |
| 59 | hashHex: hex.EncodeToString(sum[:]), |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func (d waitingDevice) start(t *testing.T, h http.Handler, cookie string) { |
| 64 | t.Helper() |
| 65 | rec := as(t, h, cookie, http.MethodPost, "/api/link/start", map[string]string{ |
| 66 | "code_hash_hex": d.hashHex, |
| 67 | "device_public_hex": hex.EncodeToString(d.publicKey), |
| 68 | }) |
| 69 | if rec.Code != http.StatusOK { |
| 70 | t.Fatalf("link start: status %d, body %s", rec.Code, rec.Body.String()) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // approve is the enrolled device's half: look the code up, seal the Account |
| 75 | // Key to the point that comes back, post the envelope. |
| 76 | func (d waitingDevice) approve(t *testing.T, h http.Handler, cookie string, |
| 77 | accountID, accountKey []byte) { |
| 78 | t.Helper() |
| 79 | var pending struct { |
| 80 | DevicePublicHex string `json:"device_public_hex"` |
| 81 | Approved bool `json:"approved"` |
| 82 | } |
| 83 | rec := as(t, h, cookie, http.MethodGet, "/api/link/pending?h="+d.hashHex, nil) |
| 84 | if rec.Code != http.StatusOK { |
| 85 | t.Fatalf("link pending: status %d, body %s", rec.Code, rec.Body.String()) |
| 86 | } |
| 87 | decode(t, rec, &pending) |
| 88 | |
| 89 | devicePublic, err := hex.DecodeString(pending.DevicePublicHex) |
| 90 | if err != nil { |
| 91 | t.Fatalf("device key hex: %v", err) |
| 92 | } |
| 93 | envelope, err := crypto.SealTo(devicePublic, |
| 94 | crypto.AAD(accountID, crypto.ObjectHandoff, devicePublic, 1), accountKey) |
| 95 | if err != nil { |
| 96 | t.Fatalf("SealTo: %v", err) |
| 97 | } |
| 98 | rec = as(t, h, cookie, http.MethodPost, "/api/link/approve", map[string]string{ |
| 99 | "code_hash_hex": d.hashHex, |
| 100 | "sealed_hex": hex.EncodeToString(envelope), |
| 101 | }) |
| 102 | if rec.Code != http.StatusNoContent { |
| 103 | t.Fatalf("link approve: status %d, body %s", rec.Code, rec.Body.String()) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func (d waitingDevice) claim(t *testing.T, h http.Handler, cookie string) (bool, []byte) { |
| 108 | t.Helper() |
| 109 | var res struct { |
| 110 | Approved bool `json:"approved"` |
| 111 | SealedHex string `json:"sealed_hex"` |
| 112 | } |
| 113 | rec := as(t, h, cookie, http.MethodGet, "/api/link/claim?h="+d.hashHex, nil) |
| 114 | if rec.Code != http.StatusOK { |
| 115 | t.Fatalf("link claim: status %d, body %s", rec.Code, rec.Body.String()) |
| 116 | } |
| 117 | decode(t, rec, &res) |
| 118 | if !res.Approved { |
| 119 | return false, nil |
| 120 | } |
| 121 | sealed, err := hex.DecodeString(res.SealedHex) |
| 122 | if err != nil { |
| 123 | t.Fatalf("sealed hex: %v", err) |
| 124 | } |
| 125 | return true, sealed |
| 126 | } |
| 127 | |
| 128 | func TestHandoffCarriesTheAccountKey(t *testing.T) { |
| 129 | h := newApp(t) |
| 130 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 131 | |
| 132 | device := newWaitingDevice(t) |
| 133 | device.start(t, h, p.cookie) |
| 134 | |
| 135 | // Nothing to collect before an enrolled device approves, and that is a |
| 136 | // 200 rather than an error: "keep waiting" is not a failure. |
| 137 | if approved, _ := device.claim(t, h, p.cookie); approved { |
| 138 | t.Fatal("a link was claimable before it was approved") |
| 139 | } |
| 140 | |
| 141 | device.approve(t, h, p.cookie, p.id, p.accountKey) |
| 142 | |
| 143 | approved, envelope := device.claim(t, h, p.cookie) |
| 144 | if !approved { |
| 145 | t.Fatal("an approved link did not come back approved") |
| 146 | } |
| 147 | |
| 148 | got, err := crypto.OpenFrom(device.scalar, device.publicKey, |
| 149 | crypto.AAD(p.id, crypto.ObjectHandoff, device.publicKey, 1), envelope) |
| 150 | if err != nil { |
| 151 | t.Fatalf("the waiting device cannot open what the server relayed: %v", err) |
| 152 | } |
| 153 | if hex.EncodeToString(got) != hex.EncodeToString(p.accountKey) { |
| 154 | t.Errorf("handoff delivered the wrong key\n got %x\n want %x", got, p.accountKey) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // The envelope leaves exactly once. A second tab, or a reloaded page, must |
| 159 | // get nothing — otherwise a code that was on screen for ten minutes stays |
| 160 | // spendable for as long as anyone kept the tab open. |
| 161 | func TestHandoffIsSingleUse(t *testing.T) { |
| 162 | h := newApp(t) |
| 163 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 164 | |
| 165 | device := newWaitingDevice(t) |
| 166 | device.start(t, h, p.cookie) |
| 167 | device.approve(t, h, p.cookie, p.id, p.accountKey) |
| 168 | |
| 169 | if approved, _ := device.claim(t, h, p.cookie); !approved { |
| 170 | t.Fatal("first claim did not deliver") |
| 171 | } |
| 172 | rec := as(t, h, p.cookie, http.MethodGet, "/api/link/claim?h="+device.hashHex, nil) |
| 173 | if rec.Code != http.StatusNotFound { |
| 174 | t.Errorf("second claim: status %d, want 404 — the envelope was handed out twice", |
| 175 | rec.Code) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // A live code cannot be spent twice on the approving side either. Without |
| 180 | // this, a stranger who somehow learned a code could overwrite the real |
| 181 | // envelope with one the waiting device can never open, and the handoff would |
| 182 | // die silently. |
| 183 | func TestHandoffApprovalIsSingleUse(t *testing.T) { |
| 184 | h := newApp(t) |
| 185 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 186 | |
| 187 | device := newWaitingDevice(t) |
| 188 | device.start(t, h, p.cookie) |
| 189 | device.approve(t, h, p.cookie, p.id, p.accountKey) |
| 190 | |
| 191 | rec := as(t, h, p.cookie, http.MethodPost, "/api/link/approve", map[string]string{ |
| 192 | "code_hash_hex": device.hashHex, |
| 193 | "sealed_hex": hex.EncodeToString(make([]byte, 128)), |
| 194 | }) |
| 195 | if rec.Code != http.StatusNotFound { |
| 196 | t.Errorf("second approval: status %d, want 404", rec.Code) |
| 197 | } |
| 198 | |
| 199 | // And the original still opens, which is the thing that would actually |
| 200 | // have been lost. |
| 201 | approved, envelope := device.claim(t, h, p.cookie) |
| 202 | if !approved { |
| 203 | t.Fatal("the link stopped being claimable") |
| 204 | } |
| 205 | got, err := crypto.OpenFrom(device.scalar, device.publicKey, |
| 206 | crypto.AAD(p.id, crypto.ObjectHandoff, device.publicKey, 1), envelope) |
| 207 | if err != nil || hex.EncodeToString(got) != hex.EncodeToString(p.accountKey) { |
| 208 | t.Errorf("a second approval clobbered the real envelope: %v", err) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // Every link route is scoped to the session's account. This is the test that |
| 213 | // would catch that scope being dropped in a refactor — which would turn a |
| 214 | // forty-bit code into the only thing between a stranger and an Account Key. |
| 215 | func TestHandoffIsInvisibleToAnotherAccount(t *testing.T) { |
| 216 | h := newApp(t) |
| 217 | mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 218 | theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") |
| 219 | |
| 220 | device := newWaitingDevice(t) |
| 221 | device.start(t, h, mine.cookie) |
| 222 | |
| 223 | for _, c := range []struct { |
| 224 | what string |
| 225 | method string |
| 226 | path string |
| 227 | body any |
| 228 | }{ |
| 229 | {"pending", http.MethodGet, "/api/link/pending?h=" + device.hashHex, nil}, |
| 230 | {"claim", http.MethodGet, "/api/link/claim?h=" + device.hashHex, nil}, |
| 231 | {"approve", http.MethodPost, "/api/link/approve", map[string]string{ |
| 232 | "code_hash_hex": device.hashHex, |
| 233 | "sealed_hex": hex.EncodeToString(make([]byte, 128)), |
| 234 | }}, |
| 235 | } { |
| 236 | rec := as(t, h, theirs.cookie, c.method, c.path, c.body) |
| 237 | if rec.Code != http.StatusNotFound { |
| 238 | t.Errorf("%s as another account: status %d, want 404", c.what, rec.Code) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // And it is still there for the account that owns it. |
| 243 | if rec := as(t, h, mine.cookie, http.MethodGet, |
| 244 | "/api/link/pending?h="+device.hashHex, nil); rec.Code != http.StatusOK { |
| 245 | t.Errorf("the owner lost their own link: status %d", rec.Code) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // The whole point of binding the recipient's key into the AAD: an envelope |
| 250 | // relayed to a different device does not open, even by a server that stored |
| 251 | // it and chose who to hand it to. |
| 252 | func TestHandoffEnvelopeIsBoundToOneDevice(t *testing.T) { |
| 253 | h := newApp(t) |
| 254 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 255 | |
| 256 | device := newWaitingDevice(t) |
| 257 | device.start(t, h, p.cookie) |
| 258 | device.approve(t, h, p.cookie, p.id, p.accountKey) |
| 259 | _, envelope := device.claim(t, h, p.cookie) |
| 260 | |
| 261 | other := newWaitingDevice(t) |
| 262 | if _, err := crypto.OpenFrom(other.scalar, other.publicKey, |
| 263 | crypto.AAD(p.id, crypto.ObjectHandoff, other.publicKey, 1), envelope); err == nil { |
| 264 | t.Error("an envelope opened on a device it was not sealed to") |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // An account cannot leave unbounded codes lying around. Without a cap, a |
| 269 | // stolen session could open codes until one collided with something a person |
| 270 | // was about to approve. |
| 271 | func TestHandoffCapsLiveLinks(t *testing.T) { |
| 272 | h := newApp(t) |
| 273 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 274 | |
| 275 | for i := 0; i < 5; i++ { |
| 276 | newWaitingDevice(t).start(t, h, p.cookie) |
| 277 | } |
| 278 | extra := newWaitingDevice(t) |
| 279 | rec := as(t, h, p.cookie, http.MethodPost, "/api/link/start", map[string]string{ |
| 280 | "code_hash_hex": extra.hashHex, |
| 281 | "device_public_hex": hex.EncodeToString(extra.publicKey), |
| 282 | }) |
| 283 | if rec.Code != http.StatusTooManyRequests { |
| 284 | t.Errorf("sixth live link: status %d, want 429", rec.Code) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // A refused device key is worth its own test: a server that accepted anything |
| 289 | // 65 bytes long would let a caller store a point that is not on the curve, |
| 290 | // and the failure would surface as an unopenable envelope much later. |
| 291 | func TestHandoffRefusesAMalformedDeviceKey(t *testing.T) { |
| 292 | h := newApp(t) |
| 293 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 294 | d := newWaitingDevice(t) |
| 295 | |
| 296 | for _, c := range []struct { |
| 297 | what string |
| 298 | key string |
| 299 | }{ |
| 300 | {"too short", hex.EncodeToString(make([]byte, 33))}, |
| 301 | {"not hex", "nonsense"}, |
| 302 | } { |
| 303 | rec := as(t, h, p.cookie, http.MethodPost, "/api/link/start", map[string]string{ |
| 304 | "code_hash_hex": d.hashHex, |
| 305 | "device_public_hex": c.key, |
| 306 | }) |
| 307 | if rec.Code != http.StatusBadRequest { |
| 308 | t.Errorf("%s device key: status %d, want 400", c.what, rec.Code) |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // Cancelling is what a closed tab should have done. A code left live for ten |
| 314 | // minutes after its device is gone is a code somebody can still be talked |
| 315 | // into approving. |
| 316 | func TestHandoffCancelRemovesTheCode(t *testing.T) { |
| 317 | h := newApp(t) |
| 318 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 319 | |
| 320 | device := newWaitingDevice(t) |
| 321 | device.start(t, h, p.cookie) |
| 322 | if rec := as(t, h, p.cookie, http.MethodPost, "/api/link/cancel", |
| 323 | map[string]string{"code_hash_hex": device.hashHex}); rec.Code != http.StatusNoContent { |
| 324 | t.Fatalf("cancel: status %d", rec.Code) |
| 325 | } |
| 326 | if rec := as(t, h, p.cookie, http.MethodGet, |
| 327 | "/api/link/pending?h="+device.hashHex, nil); rec.Code != http.StatusNotFound { |
| 328 | t.Errorf("after cancel: status %d, want 404", rec.Code) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | // The sweeper. An expired link is already inert — every query carries the |
| 333 | // expiry — so what this protects is not correctness but the table not |
| 334 | // becoming a log of when each account enrolled a device. |
| 335 | // |
| 336 | // The clock is passed in rather than waited for, so this is deterministic |
| 337 | // and takes no time. |
| 338 | func TestSweepRemovesExpiredLinks(t *testing.T) { |
| 339 | h, gdb := newAppWithDB(t) |
| 340 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 341 | |
| 342 | device := newWaitingDevice(t) |
| 343 | device.start(t, h, p.cookie) |
| 344 | |
| 345 | s := store.New(gdb) |
| 346 | ctx := context.Background() |
| 347 | |
| 348 | // Nothing to do while it is live. |
| 349 | if n, err := s.SweepLinks(ctx, time.Now().UTC()); err != nil || n != 0 { |
| 350 | t.Fatalf("swept %d live links (err %v), want 0", n, err) |
| 351 | } |
| 352 | |
| 353 | // One TTL later, it goes. |
| 354 | later := time.Now().UTC().Add(store.LinkTTL + time.Minute) |
| 355 | n, err := s.SweepLinks(ctx, later) |
| 356 | if err != nil { |
| 357 | t.Fatalf("SweepLinks: %v", err) |
| 358 | } |
| 359 | if n != 1 { |
| 360 | t.Errorf("swept %d expired links, want 1", n) |
| 361 | } |
| 362 | if n, _ := s.SweepLinks(ctx, later); n != 0 { |
| 363 | t.Errorf("a second sweep removed %d rows, want 0", n) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | // An expired code is refused even before the sweeper reaches it, because the |
| 368 | // expiry is in the query. That ordering matters: if it were only the sweeper, |
| 369 | // a code would stay live for up to five minutes past its deadline. |
| 370 | func TestExpiredLinkIsRefusedBeforeItIsSwept(t *testing.T) { |
| 371 | h, gdb := newAppWithDB(t) |
| 372 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 373 | |
| 374 | device := newWaitingDevice(t) |
| 375 | device.start(t, h, p.cookie) |
| 376 | |
| 377 | s := store.New(gdb) |
| 378 | later := time.Now().UTC().Add(store.LinkTTL + time.Minute) |
| 379 | codeHash, err := hex.DecodeString(device.hashHex) |
| 380 | if err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | if _, err := s.PendingLink(context.Background(), p.id, codeHash, later); err == nil { |
| 384 | t.Error("an expired link was still resolvable") |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | // The ladder's one rule: no screen dead-ends. This asserts the page really |
| 389 | // does offer every rung and a way back, because "we built three rungs" and |
| 390 | // "all three are on the screen" are different facts, and only the second is |
| 391 | // what a person stranded on a new laptop needs. |
| 392 | func TestDeviceLadderOffersEveryRung(t *testing.T) { |
| 393 | h := newApp(t) |
| 394 | p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") |
| 395 | |
| 396 | body := as(t, h, p.cookie, http.MethodGet, "/device", nil).Body.String() |
| 397 | for _, want := range []string{ |
| 398 | `data-rung="handoff"`, // from another signed-in device |
| 399 | `data-rung="scan"`, // from the card's QR, where the browser can |
| 400 | `data-rung="type"`, // typed off the card, which always works |
| 401 | `href="/vault"`, // and a way back |
| 402 | } { |
| 403 | if !strings.Contains(body, want) { |
| 404 | t.Errorf("the device page is missing %s — a rung, or the way out", want) |
| 405 | } |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | // A signed-out browser must not be able to open a handoff, or the code stops |
| 410 | // being a confirmation and becomes the only thing in the way. |
| 411 | func TestLinkRoutesRefuseAStranger(t *testing.T) { |
| 412 | h := newApp(t) |
| 413 | d := newWaitingDevice(t) |
| 414 | for _, c := range []struct { |
| 415 | method string |
| 416 | path string |
| 417 | }{ |
| 418 | {http.MethodPost, "/api/link/start"}, |
| 419 | {http.MethodGet, "/api/link/pending?h=" + d.hashHex}, |
| 420 | {http.MethodGet, "/api/link/claim?h=" + d.hashHex}, |
| 421 | {http.MethodPost, "/api/link/approve"}, |
| 422 | {http.MethodGet, "/device"}, |
| 423 | } { |
| 424 | rec := as(t, h, "", c.method, c.path, map[string]string{}) |
| 425 | if rec.Code == http.StatusOK || rec.Code == http.StatusNoContent { |
| 426 | t.Errorf("%s %s served a stranger: status %d", c.method, c.path, rec.Code) |
| 427 | } |
| 428 | } |
| 429 | } |
| 430 | |