| 1 | package contratest |
| 2 | |
| 3 | import ( |
| 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. |
| 31 | type 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 | |
| 45 | type sealedWrap struct { |
| 46 | id []byte |
| 47 | kek []byte |
| 48 | nonce []byte |
| 49 | } |
| 50 | |
| 51 | func 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 | |
| 65 | func 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. |
| 77 | func 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. |
| 189 | func 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. |
| 206 | func 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 | |
| 266 | func 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 | |
| 289 | func 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 | |
| 296 | func 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. |
| 308 | func 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. |
| 346 | func 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. |
| 356 | func 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. |
| 391 | func 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. |
| 439 | func 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 | |
| 457 | func 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. |
| 469 | func 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. |
| 486 | func 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 | |