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 "bytes"
5 "crypto/ecdsa"
6 "crypto/elliptic"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "net/http"
12 "net/http/httptest"
13 "strings"
14 "testing"
15
16 "github.com/carlosframework/contra/internal/contra/auth"
17 "github.com/carlosframework/contra/internal/contra/crypto"
18 "github.com/carlosframework/contra/internal/contra/model"
19)
20
21// The signup and sign-in ceremonies, driven the way a browser drives them.
22//
23// The client half is written out here in Go rather than mocked, and that is
24// the point: it exercises the same derivations internal/contra/crypto pins to
25// the golden vectors, so a divergence between what the server expects and
26// what a client produces fails here rather than on somebody's laptop. The JS
27// client runs the identical sequence — test/crypto.test.mjs holds it to the
28// same vectors.
29
30// account is what one client ceremony produces and remembers.
31type account struct {
32 email string
33 passphrase string
34 cookie string
35 id []byte
36 salt []byte
37 accountKey []byte
38 recovery []byte
39 rk []byte
40 // wraps records what the client sealed, so a test can prove the row that
41 // comes back out of storage still opens under the AAD it went in with.
42 wraps map[string]sealedWrap
43}
44
45type sealedWrap struct {
46 id []byte
47 kek []byte
48 nonce []byte
49}
50
51func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
52 t.Helper()
53 raw, err := json.Marshal(body)
54 if err != nil {
55 t.Fatalf("marshal: %v", err)
56 }
57 req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
58 req.Header.Set("Content-Type", "application/json")
59 req.Header.Set("Origin", testOrigin) // the same-origin evidence csrf.Protect wants
60 rec := httptest.NewRecorder()
61 h.ServeHTTP(rec, req)
62 return rec
63}
64
65func decode(t *testing.T, rec *httptest.ResponseRecorder, v any) {
66 t.Helper()
67 if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
68 t.Fatalf("decode %s: %v", rec.Body.String(), err)
69 }
70}
71
72// doSignup runs the whole client side: generate RK and the two printed
73// values, stretch, derive, seal, and post only the sealed halves.
74// doSignup walks the whole staged ceremony the way a person does: ask for a
75// link, follow the link, then choose a passphrase. It needs the outbox, so it
76// takes the app and the box together.
77func doSignup(t *testing.T, h http.Handler, email, passphrase string) account {
78 t.Helper()
79 box := mailboxFor(t, h)
80
81 // Step 1: the address.
82 if rec := postJSON(t, h, "/auth/begin", map[string]string{"email": email}); rec.Code != http.StatusOK {
83 t.Fatalf("begin: status %d, body %s", rec.Code, rec.Body.String())
84 }
85 // Step 1b: follow the link, which mints the signup session.
86 link := box.linkFor(t, email)
87 req := httptest.NewRequest(http.MethodGet, link, nil)
88 rec := httptest.NewRecorder()
89 h.ServeHTTP(rec, req)
90 if rec.Code != http.StatusSeeOther {
91 t.Fatalf("verify link: status %d", rec.Code)
92 }
93 signupCookie := strings.SplitN(rec.Header().Get("Set-Cookie"), ";", 2)[0]
94 if signupCookie == "" {
95 t.Fatal("following the link minted no signup session")
96 }
97
98 a := account{email: email, passphrase: passphrase, wraps: map[string]sealedWrap{},
99 cookie: signupCookie}
100 for _, dst := range []*[]byte{&a.id, &a.salt, &a.accountKey, &a.recovery} {
101 b := make([]byte, 16)
102 if _, err := rand.Read(b); err != nil {
103 t.Fatal(err)
104 }
105 *dst = b
106 }
107 a.rk = make([]byte, 32)
108 if _, err := rand.Read(a.rk); err != nil {
109 t.Fatal(err)
110 }
111
112 sPW := crypto.StretchPassphrase(passphrase, a.salt)
113 seedLogin, err := crypto.Derive(sPW, nil, crypto.LabelLogin, 32)
114 if err != nil {
115 t.Fatal(err)
116 }
117 lkPW, err := crypto.DeriveP256(seedLogin)
118 if err != nil {
119 t.Fatal(err)
120 }
121 kekPW, err := crypto.Derive(sPW, a.accountKey, crypto.LabelKEK, 32)
122 if err != nil {
123 t.Fatal(err)
124 }
125
126 seedRC, err := crypto.Derive(a.recovery, nil, crypto.LabelRCLogin, 32)
127 if err != nil {
128 t.Fatal(err)
129 }
130 lkRC, err := crypto.DeriveP256(seedRC)
131 if err != nil {
132 t.Fatal(err)
133 }
134 kekRC, err := crypto.Derive(a.recovery, nil, crypto.LabelRCKEK, 32)
135 if err != nil {
136 t.Fatal(err)
137 }
138
139 type wrapJSON struct {
140 Kind string `json:"kind"`
141 WrapIDHex string `json:"wrap_id_hex"`
142 KDFParams string `json:"kdf_params"`
143 Version uint64 `json:"version"`
144 KCVHex string `json:"kcv_hex"`
145 CredSaltHex string `json:"cred_salt_hex"`
146 NonceHex string `json:"nonce_hex"`
147 CiphertextHex string `json:"ciphertext_hex"`
148 }
149 seal := func(kind string, kek []byte) wrapJSON {
150 wrapID := make([]byte, 16)
151 nonce := make([]byte, 12)
152 if _, err := rand.Read(wrapID); err != nil {
153 t.Fatal(err)
154 }
155 if _, err := rand.Read(nonce); err != nil {
156 t.Fatal(err)
157 }
158 // The account id and the wrap id are both the client's, and both are
159 // bound into the AAD here — before the server has seen any of it.
160 ct, err := crypto.Seal(kek, nonce, crypto.AAD(a.id, kind, wrapID, 1), a.rk)
161 if err != nil {
162 t.Fatal(err)
163 }
164 a.wraps[kind] = sealedWrap{id: wrapID, kek: kek, nonce: nonce}
165 return wrapJSON{
166 Kind: kind, WrapIDHex: hex.EncodeToString(wrapID),
167 KDFParams: auth.DefaultKDFParams, Version: 1,
168 KCVHex: hex.EncodeToString(crypto.KCV(kek)),
169 NonceHex: hex.EncodeToString(nonce), CiphertextHex: hex.EncodeToString(ct),
170 }
171 }
172
173 rec = postAs(t, h, a.cookie, "/auth/complete", map[string]any{
174 "account_id_hex": hex.EncodeToString(a.id),
175 "salt_hex": hex.EncodeToString(a.salt),
176 "login_keys": []map[string]string{
177 {"branch": "pw", "public_key_hex": hex.EncodeToString(lkPW.PublicKey().Bytes())},
178 {"branch": "rc", "public_key_hex": hex.EncodeToString(lkRC.PublicKey().Bytes())},
179 },
180 "wraps": []wrapJSON{seal(crypto.ObjectWrapPW, kekPW), seal(crypto.ObjectWrapRC, kekRC)},
181 })
182 if rec.Code != http.StatusCreated {
183 t.Fatalf("complete: status %d, body %s", rec.Code, rec.Body.String())
184 }
185 return a
186}
187
188// postAs is postJSON carrying a cookie.
189func postAs(t *testing.T, h http.Handler, cookie, path string, body any) *httptest.ResponseRecorder {
190 t.Helper()
191 raw, err := json.Marshal(body)
192 if err != nil {
193 t.Fatal(err)
194 }
195 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw)))
196 req.Header.Set("Content-Type", "application/json")
197 req.Header.Set("Origin", testOrigin)
198 req.Header.Set("Cookie", cookie)
199 rec := httptest.NewRecorder()
200 h.ServeHTTP(rec, req)
201 return rec
202}
203
204// doSignIn runs the challenge ceremony and returns the response, so a test
205// can assert on a refusal as easily as on a success.
206func doSignIn(t *testing.T, h http.Handler, email, passphrase string) *httptest.ResponseRecorder {
207 t.Helper()
208
209 var ch struct {
210 ChallengeHex string `json:"challenge_hex"`
211 SaltHex string `json:"salt_hex"`
212 KDFParams string `json:"kdf_params"`
213 }
214 rec := postJSON(t, h, "/auth/challenge", map[string]string{"email": email, "branch": "pw"})
215 if rec.Code != http.StatusOK {
216 t.Fatalf("challenge: status %d", rec.Code)
217 }
218 decode(t, rec, &ch)
219
220 // The client's floor, checked on parameters the server sent, before a
221 // single expensive byte is derived.
222 if err := auth.CheckParams(ch.KDFParams); err != nil {
223 t.Fatalf("server offered parameters below the floor: %v", err)
224 }
225
226 salt, err := hex.DecodeString(ch.SaltHex)
227 if err != nil {
228 t.Fatal(err)
229 }
230 challenge, err := hex.DecodeString(ch.ChallengeHex)
231 if err != nil {
232 t.Fatal(err)
233 }
234
235 sPW := crypto.StretchPassphrase(passphrase, salt)
236 seedLogin, err := crypto.Derive(sPW, nil, crypto.LabelLogin, 32)
237 if err != nil {
238 t.Fatal(err)
239 }
240 key, err := crypto.DeriveP256(seedLogin)
241 if err != nil {
242 t.Fatal(err)
243 }
244
245 priv, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), key.Bytes())
246 if err != nil {
247 t.Fatal(err)
248 }
249 digest := sha256.Sum256(auth.SignedMessage("pw", challenge))
250 r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:])
251 if err != nil {
252 t.Fatal(err)
253 }
254 sig := make([]byte, 64)
255 r.FillBytes(sig[:32])
256 s.FillBytes(sig[32:])
257
258 return postJSON(t, h, "/auth/verify", map[string]string{
259 "email": email,
260 "branch": "pw",
261 "challenge_hex": ch.ChallengeHex,
262 "signature_hex": hex.EncodeToString(sig),
263 })
264}
265
266func TestSignupThenSignIn(t *testing.T) {
267 h := newApp(t)
268 a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c")
269
270 rec := doSignIn(t, h, a.email, a.passphrase)
271 if rec.Code != http.StatusOK {
272 t.Fatalf("sign in: status %d, body %s", rec.Code, rec.Body.String())
273 }
274 cookie := rec.Header().Get("Set-Cookie")
275 if cookie == "" {
276 t.Fatal("sign in minted no session cookie")
277 }
278
279 // And the session actually opens the signed-in surface.
280 req := httptest.NewRequest(http.MethodGet, "/vault", nil)
281 req.Header.Set("Cookie", strings.SplitN(cookie, ";", 2)[0])
282 vault := httptest.NewRecorder()
283 h.ServeHTTP(vault, req)
284 if vault.Code != http.StatusOK {
285 t.Fatalf("GET /vault with a session: status %d", vault.Code)
286 }
287}
288
289func TestVaultRefusesAStranger(t *testing.T) {
290 rec := get(t, newApp(t), "/vault")
291 if rec.Code == http.StatusOK {
292 t.Fatal("GET /vault served a signed-out request")
293 }
294}
295
296func TestWrongPassphraseIsRefused(t *testing.T) {
297 h := newApp(t)
298 a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c")
299
300 rec := doSignIn(t, h, a.email, "a generated test passphrase 8f3a1d")
301 if rec.Code != http.StatusUnauthorized {
302 t.Fatalf("wrong passphrase: status %d, want 401", rec.Code)
303 }
304}
305
306// A challenge is spent whether or not the signature was good, so an attacker
307// cannot grind attempts against one issued challenge.
308func TestChallengeIsSingleUse(t *testing.T) {
309 h := newApp(t)
310 a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c")
311
312 var ch struct {
313 ChallengeHex string `json:"challenge_hex"`
314 SaltHex string `json:"salt_hex"`
315 }
316 decode(t, postJSON(t, h, "/auth/challenge",
317 map[string]string{"email": a.email, "branch": "pw"}), &ch)
318
319 challenge, _ := hex.DecodeString(ch.ChallengeHex)
320 salt, _ := hex.DecodeString(ch.SaltHex)
321 sPW := crypto.StretchPassphrase(a.passphrase, salt)
322 seed, _ := crypto.Derive(sPW, nil, crypto.LabelLogin, 32)
323 key, _ := crypto.DeriveP256(seed)
324 priv, _ := ecdsa.ParseRawPrivateKey(elliptic.P256(), key.Bytes())
325 digest := sha256.Sum256(auth.SignedMessage("pw", challenge))
326 r, s, _ := ecdsa.Sign(rand.Reader, priv, digest[:])
327 sig := make([]byte, 64)
328 r.FillBytes(sig[:32])
329 s.FillBytes(sig[32:])
330
331 body := map[string]string{
332 "email": a.email, "branch": "pw",
333 "challenge_hex": ch.ChallengeHex, "signature_hex": hex.EncodeToString(sig),
334 }
335 if rec := postJSON(t, h, "/auth/verify", body); rec.Code != http.StatusOK {
336 t.Fatalf("first use: status %d", rec.Code)
337 }
338 if rec := postJSON(t, h, "/auth/verify", body); rec.Code != http.StatusUnauthorized {
339 t.Errorf("replayed challenge: status %d, want 401", rec.Code)
340 }
341}
342
343// A challenge issued for the recovery credential must not be spendable on the
344// passphrase one — which is why the branch is bound into the signed message
345// rather than only checked alongside it.
346func TestChallengeIsBoundToItsBranch(t *testing.T) {
347 challenge := bytes.Repeat([]byte{0xab}, 32)
348 if bytes.Equal(auth.SignedMessage("pw", challenge), auth.SignedMessage("rc", challenge)) {
349 t.Fatal("the same bytes are signed for both branches")
350 }
351}
352
353// The salt endpoint must not become an account-existence oracle. An unknown
354// address gets a deterministic decoy, so asking twice cannot tell it from a
355// real one by variance.
356func TestSaltDoesNotLeakAccountExistence(t *testing.T) {
357 h := newApp(t)
358 known := "vector-person@example.invalid"
359 doSignup(t, h, known, "a generated test passphrase 8f3a1c")
360
361 saltFor := func(email string) string {
362 var r struct {
363 SaltHex string `json:"salt_hex"`
364 KDFParams string `json:"kdf_params"`
365 }
366 decode(t, postJSON(t, h, "/auth/salt", map[string]string{"email": email}), &r)
367 if len(r.SaltHex) != 32 {
368 t.Fatalf("salt for %q is %d hex chars, want 32 — a decoy that is the "+
369 "wrong shape is not a decoy", email, len(r.SaltHex))
370 }
371 if r.KDFParams == "" {
372 t.Fatalf("no parameters offered for %q", email)
373 }
374 return r.SaltHex
375 }
376
377 unknown := "nobody-here@example.invalid"
378 first, second := saltFor(unknown), saltFor(unknown)
379 if first != second {
380 t.Error("an unknown address got two different salts; the variance itself " +
381 "says there is no account")
382 }
383 if first == saltFor(known) {
384 t.Error("the decoy collided with a real salt")
385 }
386}
387
388// The KDF floor, server side. The browser is where this defence really lives
389// — a malicious server cannot protect anyone from itself — but a client bug
390// must not be able to persist parameters a later, honest client would trust.
391func TestServerRefusesDowngradedKDFParameters(t *testing.T) {
392 for _, params := range []string{
393 `{"alg":"argon2id","m_kib":8,"t":1,"p":1}`,
394 `{"alg":"argon2id","m_kib":65535,"t":3,"p":1}`,
395 `{"alg":"argon2id","m_kib":65536,"t":2,"p":1}`,
396 `{"alg":"argon2id","m_kib":65536,"t":3,"p":4}`,
397 `{"alg":"pbkdf2","m_kib":65536,"t":3,"p":1}`,
398 `not json`,
399 } {
400 if err := auth.CheckParams(params); err == nil {
401 t.Errorf("CheckParams accepted %s", params)
402 }
403 }
404 if err := auth.CheckParams(auth.DefaultKDFParams); err != nil {
405 t.Errorf("CheckParams rejected its own default: %v", err)
406 }
407
408 // And the endpoint refuses them, not just the helper.
409 h, _, box := newAppWithMail(t)
410 const weak = "weak@example.invalid"
411 postJSON(t, h, "/auth/begin", map[string]string{"email": weak})
412 vr := httptest.NewRecorder()
413 h.ServeHTTP(vr, httptest.NewRequest(http.MethodGet, box.linkFor(t, weak), nil))
414 cookie := strings.SplitN(vr.Header().Get("Set-Cookie"), ";", 2)[0]
415
416 rec := postAs(t, h, cookie, "/auth/complete", map[string]any{
417 "account_id_hex": hex.EncodeToString(make([]byte, 16)),
418 "salt_hex": hex.EncodeToString(make([]byte, 16)),
419 "login_keys": []map[string]string{},
420 "wraps": []map[string]any{{
421 "kind": crypto.ObjectWrapPW,
422 "wrap_id_hex": hex.EncodeToString(make([]byte, 16)),
423 "kdf_params": `{"alg":"argon2id","m_kib":8,"t":1,"p":1}`,
424 "version": 1,
425 "kcv_hex": hex.EncodeToString(make([]byte, 16)),
426 "cred_salt_hex": "",
427 "nonce_hex": hex.EncodeToString(make([]byte, 12)),
428 "ciphertext_hex": hex.EncodeToString(make([]byte, 48)),
429 }},
430 })
431 if rec.Code != http.StatusBadRequest {
432 t.Errorf("signup with m=8KiB: status %d, want 400", rec.Code)
433 }
434}
435
436// The blind index is the whole of what the server knows about an address.
437// Different addresses give different indices; the same address always gives
438// the same one; and nothing about the address survives in it.
439func TestBlindIndexIsStableAndOpaque(t *testing.T) {
440 a := newAuthForIndexTest(t)
441 one := a.BlindIndex("Person@Example.Invalid")
442 two := a.BlindIndex(" person@example.invalid ")
443 if !bytes.Equal(one, two) {
444 t.Error("normalisation is not reaching the index")
445 }
446 if bytes.Equal(one, a.BlindIndex("other@example.invalid")) {
447 t.Error("two addresses share an index")
448 }
449 if len(one) != 32 {
450 t.Errorf("index is %d bytes, want 32", len(one))
451 }
452 if bytes.Contains(one, []byte("person")) || bytes.Contains(one, []byte("example")) {
453 t.Error("the address survives in its own index")
454 }
455}
456
457func newAuthForIndexTest(t *testing.T) *auth.Auth {
458 t.Helper()
459 a, err := auth.New(nil, nil, testPepper, nil, testOrigin, nil)
460 if err != nil {
461 t.Fatalf("auth.New: %v", err)
462 }
463 return a
464}
465
466// A pepper that is absent or short is refused rather than invented: an
467// ephemeral one would change every account's index on the next restart and
468// strand every account there is.
469func TestAuthRefusesAWeakPepper(t *testing.T) {
470 for _, p := range [][]byte{nil, []byte(""), []byte("too short")} {
471 if _, err := auth.New(nil, nil, p, nil, testOrigin, nil); err == nil {
472 t.Errorf("auth.New accepted a %d-byte pepper", len(p))
473 }
474 }
475}
476
477// The bug this test exists for: the server minted its own wrap id while the
478// client had already bound *its* id into the AAD, so every wrap it stored was
479// one no client could ever open.
480//
481// Nothing caught it, because the Go tests built the request struct by hand and
482// so agreed with themselves. It surfaced the first time the real browser
483// module talked to the real server. This asserts the property rather than the
484// plumbing: what comes back out of storage still opens under the AAD it went
485// in with, so an id minted on the wrong side of the wire fails here.
486func TestStoredWrapsStillOpen(t *testing.T) {
487 h, d := newAppWithDB(t)
488 a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c")
489
490 if len(a.wraps) == 0 {
491 t.Fatal("the ceremony sealed no wraps, so this test is checking nothing")
492 }
493 for kind, w := range a.wraps {
494 var row model.Wrap
495 if err := d.Where("kind = ?", kind).First(&row).Error; err != nil {
496 t.Errorf("%s: not stored: %v", kind, err)
497 continue
498 }
499 if !bytes.Equal(row.ID, w.id) {
500 t.Errorf("%s: stored id %x, but the client sealed against %x — this "+
501 "wrap can never be opened", kind, row.ID, w.id)
502 continue
503 }
504 if !bytes.Equal(row.AccountID, a.id) {
505 t.Errorf("%s: stored account id %x, client sealed against %x",
506 kind, row.AccountID, a.id)
507 continue
508 }
509 got, err := crypto.Open(w.kek, row.Nonce,
510 crypto.AAD(row.AccountID, kind, row.ID, row.Version), row.Ciphertext)
511 if err != nil {
512 t.Errorf("%s: the stored wrap does not open: %v", kind, err)
513 continue
514 }
515 if !bytes.Equal(got, a.rk) {
516 t.Errorf("%s: opened to something other than RK", kind)
517 }
518 }
519}
520