package contratest import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/carlosframework/contra/internal/contra/auth" "github.com/carlosframework/contra/internal/contra/crypto" "github.com/carlosframework/contra/internal/contra/model" ) // The signup and sign-in ceremonies, driven the way a browser drives them. // // The client half is written out here in Go rather than mocked, and that is // the point: it exercises the same derivations internal/contra/crypto pins to // the golden vectors, so a divergence between what the server expects and // what a client produces fails here rather than on somebody's laptop. The JS // client runs the identical sequence — test/crypto.test.mjs holds it to the // same vectors. // account is what one client ceremony produces and remembers. type account struct { email string passphrase string cookie string id []byte salt []byte accountKey []byte recovery []byte rk []byte // wraps records what the client sealed, so a test can prove the row that // comes back out of storage still opens under the AAD it went in with. wraps map[string]sealedWrap } type sealedWrap struct { id []byte kek []byte nonce []byte } func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatalf("marshal: %v", err) } req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", testOrigin) // the same-origin evidence csrf.Protect wants rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func decode(t *testing.T, rec *httptest.ResponseRecorder, v any) { t.Helper() if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { t.Fatalf("decode %s: %v", rec.Body.String(), err) } } // doSignup runs the whole client side: generate RK and the two printed // values, stretch, derive, seal, and post only the sealed halves. // doSignup walks the whole staged ceremony the way a person does: ask for a // link, follow the link, then choose a passphrase. It needs the outbox, so it // takes the app and the box together. func doSignup(t *testing.T, h http.Handler, email, passphrase string) account { t.Helper() box := mailboxFor(t, h) // Step 1: the address. if rec := postJSON(t, h, "/auth/begin", map[string]string{"email": email}); rec.Code != http.StatusOK { t.Fatalf("begin: status %d, body %s", rec.Code, rec.Body.String()) } // Step 1b: follow the link, which mints the signup session. link := box.linkFor(t, email) req := httptest.NewRequest(http.MethodGet, link, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("verify link: status %d", rec.Code) } signupCookie := strings.SplitN(rec.Header().Get("Set-Cookie"), ";", 2)[0] if signupCookie == "" { t.Fatal("following the link minted no signup session") } a := account{email: email, passphrase: passphrase, wraps: map[string]sealedWrap{}, cookie: signupCookie} for _, dst := range []*[]byte{&a.id, &a.salt, &a.accountKey, &a.recovery} { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { t.Fatal(err) } *dst = b } a.rk = make([]byte, 32) if _, err := rand.Read(a.rk); err != nil { t.Fatal(err) } sPW := crypto.StretchPassphrase(passphrase, a.salt) seedLogin, err := crypto.Derive(sPW, nil, crypto.LabelLogin, 32) if err != nil { t.Fatal(err) } lkPW, err := crypto.DeriveP256(seedLogin) if err != nil { t.Fatal(err) } kekPW, err := crypto.Derive(sPW, a.accountKey, crypto.LabelKEK, 32) if err != nil { t.Fatal(err) } seedRC, err := crypto.Derive(a.recovery, nil, crypto.LabelRCLogin, 32) if err != nil { t.Fatal(err) } lkRC, err := crypto.DeriveP256(seedRC) if err != nil { t.Fatal(err) } kekRC, err := crypto.Derive(a.recovery, nil, crypto.LabelRCKEK, 32) if err != nil { t.Fatal(err) } type wrapJSON struct { Kind string `json:"kind"` WrapIDHex string `json:"wrap_id_hex"` KDFParams string `json:"kdf_params"` Version uint64 `json:"version"` KCVHex string `json:"kcv_hex"` CredSaltHex string `json:"cred_salt_hex"` NonceHex string `json:"nonce_hex"` CiphertextHex string `json:"ciphertext_hex"` } seal := func(kind string, kek []byte) wrapJSON { wrapID := make([]byte, 16) nonce := make([]byte, 12) if _, err := rand.Read(wrapID); err != nil { t.Fatal(err) } if _, err := rand.Read(nonce); err != nil { t.Fatal(err) } // The account id and the wrap id are both the client's, and both are // bound into the AAD here — before the server has seen any of it. ct, err := crypto.Seal(kek, nonce, crypto.AAD(a.id, kind, wrapID, 1), a.rk) if err != nil { t.Fatal(err) } a.wraps[kind] = sealedWrap{id: wrapID, kek: kek, nonce: nonce} return wrapJSON{ Kind: kind, WrapIDHex: hex.EncodeToString(wrapID), KDFParams: auth.DefaultKDFParams, Version: 1, KCVHex: hex.EncodeToString(crypto.KCV(kek)), NonceHex: hex.EncodeToString(nonce), CiphertextHex: hex.EncodeToString(ct), } } rec = postAs(t, h, a.cookie, "/auth/complete", map[string]any{ "account_id_hex": hex.EncodeToString(a.id), "salt_hex": hex.EncodeToString(a.salt), "login_keys": []map[string]string{ {"branch": "pw", "public_key_hex": hex.EncodeToString(lkPW.PublicKey().Bytes())}, {"branch": "rc", "public_key_hex": hex.EncodeToString(lkRC.PublicKey().Bytes())}, }, "wraps": []wrapJSON{seal(crypto.ObjectWrapPW, kekPW), seal(crypto.ObjectWrapRC, kekRC)}, }) if rec.Code != http.StatusCreated { t.Fatalf("complete: status %d, body %s", rec.Code, rec.Body.String()) } return a } // postAs is postJSON carrying a cookie. func postAs(t *testing.T, h http.Handler, cookie, path string, body any) *httptest.ResponseRecorder { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", testOrigin) req.Header.Set("Cookie", cookie) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // doSignIn runs the challenge ceremony and returns the response, so a test // can assert on a refusal as easily as on a success. func doSignIn(t *testing.T, h http.Handler, email, passphrase string) *httptest.ResponseRecorder { t.Helper() var ch struct { ChallengeHex string `json:"challenge_hex"` SaltHex string `json:"salt_hex"` KDFParams string `json:"kdf_params"` } rec := postJSON(t, h, "/auth/challenge", map[string]string{"email": email, "branch": "pw"}) if rec.Code != http.StatusOK { t.Fatalf("challenge: status %d", rec.Code) } decode(t, rec, &ch) // The client's floor, checked on parameters the server sent, before a // single expensive byte is derived. if err := auth.CheckParams(ch.KDFParams); err != nil { t.Fatalf("server offered parameters below the floor: %v", err) } salt, err := hex.DecodeString(ch.SaltHex) if err != nil { t.Fatal(err) } challenge, err := hex.DecodeString(ch.ChallengeHex) if err != nil { t.Fatal(err) } sPW := crypto.StretchPassphrase(passphrase, salt) seedLogin, err := crypto.Derive(sPW, nil, crypto.LabelLogin, 32) if err != nil { t.Fatal(err) } key, err := crypto.DeriveP256(seedLogin) if err != nil { t.Fatal(err) } priv, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), key.Bytes()) if err != nil { t.Fatal(err) } digest := sha256.Sum256(auth.SignedMessage("pw", challenge)) r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:]) if err != nil { t.Fatal(err) } sig := make([]byte, 64) r.FillBytes(sig[:32]) s.FillBytes(sig[32:]) return postJSON(t, h, "/auth/verify", map[string]string{ "email": email, "branch": "pw", "challenge_hex": ch.ChallengeHex, "signature_hex": hex.EncodeToString(sig), }) } func TestSignupThenSignIn(t *testing.T) { h := newApp(t) a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") rec := doSignIn(t, h, a.email, a.passphrase) if rec.Code != http.StatusOK { t.Fatalf("sign in: status %d, body %s", rec.Code, rec.Body.String()) } cookie := rec.Header().Get("Set-Cookie") if cookie == "" { t.Fatal("sign in minted no session cookie") } // And the session actually opens the signed-in surface. req := httptest.NewRequest(http.MethodGet, "/vault", nil) req.Header.Set("Cookie", strings.SplitN(cookie, ";", 2)[0]) vault := httptest.NewRecorder() h.ServeHTTP(vault, req) if vault.Code != http.StatusOK { t.Fatalf("GET /vault with a session: status %d", vault.Code) } } func TestVaultRefusesAStranger(t *testing.T) { rec := get(t, newApp(t), "/vault") if rec.Code == http.StatusOK { t.Fatal("GET /vault served a signed-out request") } } func TestWrongPassphraseIsRefused(t *testing.T) { h := newApp(t) a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") rec := doSignIn(t, h, a.email, "a generated test passphrase 8f3a1d") if rec.Code != http.StatusUnauthorized { t.Fatalf("wrong passphrase: status %d, want 401", rec.Code) } } // A challenge is spent whether or not the signature was good, so an attacker // cannot grind attempts against one issued challenge. func TestChallengeIsSingleUse(t *testing.T) { h := newApp(t) a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") var ch struct { ChallengeHex string `json:"challenge_hex"` SaltHex string `json:"salt_hex"` } decode(t, postJSON(t, h, "/auth/challenge", map[string]string{"email": a.email, "branch": "pw"}), &ch) challenge, _ := hex.DecodeString(ch.ChallengeHex) salt, _ := hex.DecodeString(ch.SaltHex) sPW := crypto.StretchPassphrase(a.passphrase, salt) seed, _ := crypto.Derive(sPW, nil, crypto.LabelLogin, 32) key, _ := crypto.DeriveP256(seed) priv, _ := ecdsa.ParseRawPrivateKey(elliptic.P256(), key.Bytes()) digest := sha256.Sum256(auth.SignedMessage("pw", challenge)) r, s, _ := ecdsa.Sign(rand.Reader, priv, digest[:]) sig := make([]byte, 64) r.FillBytes(sig[:32]) s.FillBytes(sig[32:]) body := map[string]string{ "email": a.email, "branch": "pw", "challenge_hex": ch.ChallengeHex, "signature_hex": hex.EncodeToString(sig), } if rec := postJSON(t, h, "/auth/verify", body); rec.Code != http.StatusOK { t.Fatalf("first use: status %d", rec.Code) } if rec := postJSON(t, h, "/auth/verify", body); rec.Code != http.StatusUnauthorized { t.Errorf("replayed challenge: status %d, want 401", rec.Code) } } // A challenge issued for the recovery credential must not be spendable on the // passphrase one — which is why the branch is bound into the signed message // rather than only checked alongside it. func TestChallengeIsBoundToItsBranch(t *testing.T) { challenge := bytes.Repeat([]byte{0xab}, 32) if bytes.Equal(auth.SignedMessage("pw", challenge), auth.SignedMessage("rc", challenge)) { t.Fatal("the same bytes are signed for both branches") } } // The salt endpoint must not become an account-existence oracle. An unknown // address gets a deterministic decoy, so asking twice cannot tell it from a // real one by variance. func TestSaltDoesNotLeakAccountExistence(t *testing.T) { h := newApp(t) known := "vector-person@example.invalid" doSignup(t, h, known, "a generated test passphrase 8f3a1c") saltFor := func(email string) string { var r struct { SaltHex string `json:"salt_hex"` KDFParams string `json:"kdf_params"` } decode(t, postJSON(t, h, "/auth/salt", map[string]string{"email": email}), &r) if len(r.SaltHex) != 32 { t.Fatalf("salt for %q is %d hex chars, want 32 — a decoy that is the "+ "wrong shape is not a decoy", email, len(r.SaltHex)) } if r.KDFParams == "" { t.Fatalf("no parameters offered for %q", email) } return r.SaltHex } unknown := "nobody-here@example.invalid" first, second := saltFor(unknown), saltFor(unknown) if first != second { t.Error("an unknown address got two different salts; the variance itself " + "says there is no account") } if first == saltFor(known) { t.Error("the decoy collided with a real salt") } } // The KDF floor, server side. The browser is where this defence really lives // — a malicious server cannot protect anyone from itself — but a client bug // must not be able to persist parameters a later, honest client would trust. func TestServerRefusesDowngradedKDFParameters(t *testing.T) { for _, params := range []string{ `{"alg":"argon2id","m_kib":8,"t":1,"p":1}`, `{"alg":"argon2id","m_kib":65535,"t":3,"p":1}`, `{"alg":"argon2id","m_kib":65536,"t":2,"p":1}`, `{"alg":"argon2id","m_kib":65536,"t":3,"p":4}`, `{"alg":"pbkdf2","m_kib":65536,"t":3,"p":1}`, `not json`, } { if err := auth.CheckParams(params); err == nil { t.Errorf("CheckParams accepted %s", params) } } if err := auth.CheckParams(auth.DefaultKDFParams); err != nil { t.Errorf("CheckParams rejected its own default: %v", err) } // And the endpoint refuses them, not just the helper. h, _, box := newAppWithMail(t) const weak = "weak@example.invalid" postJSON(t, h, "/auth/begin", map[string]string{"email": weak}) vr := httptest.NewRecorder() h.ServeHTTP(vr, httptest.NewRequest(http.MethodGet, box.linkFor(t, weak), nil)) cookie := strings.SplitN(vr.Header().Get("Set-Cookie"), ";", 2)[0] rec := postAs(t, h, cookie, "/auth/complete", map[string]any{ "account_id_hex": hex.EncodeToString(make([]byte, 16)), "salt_hex": hex.EncodeToString(make([]byte, 16)), "login_keys": []map[string]string{}, "wraps": []map[string]any{{ "kind": crypto.ObjectWrapPW, "wrap_id_hex": hex.EncodeToString(make([]byte, 16)), "kdf_params": `{"alg":"argon2id","m_kib":8,"t":1,"p":1}`, "version": 1, "kcv_hex": hex.EncodeToString(make([]byte, 16)), "cred_salt_hex": "", "nonce_hex": hex.EncodeToString(make([]byte, 12)), "ciphertext_hex": hex.EncodeToString(make([]byte, 48)), }}, }) if rec.Code != http.StatusBadRequest { t.Errorf("signup with m=8KiB: status %d, want 400", rec.Code) } } // The blind index is the whole of what the server knows about an address. // Different addresses give different indices; the same address always gives // the same one; and nothing about the address survives in it. func TestBlindIndexIsStableAndOpaque(t *testing.T) { a := newAuthForIndexTest(t) one := a.BlindIndex("Person@Example.Invalid") two := a.BlindIndex(" person@example.invalid ") if !bytes.Equal(one, two) { t.Error("normalisation is not reaching the index") } if bytes.Equal(one, a.BlindIndex("other@example.invalid")) { t.Error("two addresses share an index") } if len(one) != 32 { t.Errorf("index is %d bytes, want 32", len(one)) } if bytes.Contains(one, []byte("person")) || bytes.Contains(one, []byte("example")) { t.Error("the address survives in its own index") } } func newAuthForIndexTest(t *testing.T) *auth.Auth { t.Helper() a, err := auth.New(nil, nil, testPepper, nil, testOrigin, nil) if err != nil { t.Fatalf("auth.New: %v", err) } return a } // A pepper that is absent or short is refused rather than invented: an // ephemeral one would change every account's index on the next restart and // strand every account there is. func TestAuthRefusesAWeakPepper(t *testing.T) { for _, p := range [][]byte{nil, []byte(""), []byte("too short")} { if _, err := auth.New(nil, nil, p, nil, testOrigin, nil); err == nil { t.Errorf("auth.New accepted a %d-byte pepper", len(p)) } } } // The bug this test exists for: the server minted its own wrap id while the // client had already bound *its* id into the AAD, so every wrap it stored was // one no client could ever open. // // Nothing caught it, because the Go tests built the request struct by hand and // so agreed with themselves. It surfaced the first time the real browser // module talked to the real server. This asserts the property rather than the // plumbing: what comes back out of storage still opens under the AAD it went // in with, so an id minted on the wrong side of the wire fails here. func TestStoredWrapsStillOpen(t *testing.T) { h, d := newAppWithDB(t) a := doSignup(t, h, "vector-person@example.invalid", "a generated test passphrase 8f3a1c") if len(a.wraps) == 0 { t.Fatal("the ceremony sealed no wraps, so this test is checking nothing") } for kind, w := range a.wraps { var row model.Wrap if err := d.Where("kind = ?", kind).First(&row).Error; err != nil { t.Errorf("%s: not stored: %v", kind, err) continue } if !bytes.Equal(row.ID, w.id) { t.Errorf("%s: stored id %x, but the client sealed against %x — this "+ "wrap can never be opened", kind, row.ID, w.id) continue } if !bytes.Equal(row.AccountID, a.id) { t.Errorf("%s: stored account id %x, client sealed against %x", kind, row.AccountID, a.id) continue } got, err := crypto.Open(w.kek, row.Nonce, crypto.AAD(row.AccountID, kind, row.ID, row.Version), row.Ciphertext) if err != nil { t.Errorf("%s: the stored wrap does not open: %v", kind, err) continue } if !bytes.Equal(got, a.rk) { t.Errorf("%s: opened to something other than RK", kind) } } }