carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package contratest
2
3import (
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.
27type waitingDevice struct {
28 scalar []byte
29 publicKey []byte
30 code string
31 hashHex string
32}
33
34func 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
63func (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.
76func (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
107func (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
128func 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.
161func 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.
183func 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.
215func 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.
252func 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.
271func 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.
291func 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.
316func 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.
338func 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.
370func 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.
392func 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.
411func 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