diff --git a/README.md b/README.md index 15eb862..9623ef2 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ if ok, _ := pwd.Verify("Str0ng-P@ssword!", hash); ok { authcore is an in-process library, not a hosted identity platform: it ships no database and no HTTP server of its own, generates and manages its own signing keys on first run, and each module (password, jwt, apikey, oauth, email, -username, totp) can be used independently. +username, totp, credential, field) can be used independently. ## Modules @@ -74,18 +74,20 @@ Pick only what you need — each is independent, testable, and safe by default. | | Module | Does | |---|---|---| | 🔑 | **[password](docs/password.md)** | Hash + verify. Argon2id, policy-enforced, self-describing PHC format. | -| 🎫 | **[jwt](docs/jwt.md)** | Access + refresh tokens. EdDSA / Ed25519, generic claims, rotation. | +| 🎫 | **[jwt](docs/jwt.md)** | Access + refresh tokens. EdDSA / Ed25519, generic claims, rotation, optional denylist for instant revocation. | | 📧 | **[email](docs/validation.md)** | Validate + normalize. RFC 5321/5322, optional cached DNS MX check. | | 👤 | **[username](docs/validation.md)** | Validate + normalize. Reserved-name blocklist, character rules. | | 🗝️ | **[apikey](docs/apikey.md)** | Opaque API keys. Generate, keyed-hash for storage, constant-time verify. | | 🔐 | **[totp](docs/totp.md)** | TOTP / RFC 6238 second factor. Enroll, verify (with replay protection), recovery codes. | +| ✉️ | **[credential](docs/credential.md)** | Single-use tokens for password reset and account activation. Bound to a purpose and a subject, TTL enforced. | +| 🛡️ | **[field](docs/field.md)** | Column encryption. AES-256-GCM plus an HMAC blind index, so a value stays searchable by equality without being readable. | | 🌐 | **[oauth](docs/oauth.md)** | Social login — Google, Microsoft (OIDC) and GitHub, Discord (OAuth2). Auth Code + PKCE, ID-token validation or userinfo. | ```mermaid flowchart LR App["Your app"] -->|init once| Core["authcore"] Core -->|auto-generates| Keys[("🔑 Ed25519 + HMAC
on disk")] - Core -->|Provider| M["password · jwt · apikey · oauth
email · username · totp"] + Core -->|Provider| M["password · jwt · apikey · oauth · email
username · totp · credential · field"] M -->|hash · sign · verify| App ``` @@ -94,7 +96,7 @@ flowchart LR **New here? Start with the [Secure login recipe](docs/secure-login.md)** — the step-by-step flow that turns these primitives into a login an auditor accepts. -[Secure login recipe](docs/secure-login.md) · [Password](docs/password.md) · [JWT](docs/jwt.md) · [Email & username](docs/validation.md) · [API keys](docs/apikey.md) · [TOTP](docs/totp.md) · [OIDC login](docs/oauth.md) · [Key management](docs/key-management.md) · [Configuration](docs/configuration.md) · [Testing & modules](docs/testing.md) · [Migrating from bcrypt](docs/migrating.md) · [Errors](docs/errors.md) · [FAQ](docs/faq.md) · [Versioning](docs/versioning.md) +[Secure login recipe](docs/secure-login.md) · [Password](docs/password.md) · [JWT](docs/jwt.md) · [Email & username](docs/validation.md) · [API keys](docs/apikey.md) · [TOTP](docs/totp.md) · [Credential tokens](docs/credential.md) · [Field encryption](docs/field.md) · [OIDC login](docs/oauth.md) · [Key management](docs/key-management.md) · [Configuration](docs/configuration.md) · [Testing & modules](docs/testing.md) · [Migrating from bcrypt](docs/migrating.md) · [Errors](docs/errors.md) · [FAQ](docs/faq.md) · [Versioning](docs/versioning.md) Full API reference on [pkg.go.dev](https://pkg.go.dev/github.com/Glyndor/authcore). diff --git a/auth/credential/config.go b/auth/credential/config.go new file mode 100644 index 0000000..d1d1811 --- /dev/null +++ b/auth/credential/config.go @@ -0,0 +1,84 @@ +package credential + +import ( + "fmt" + "time" +) + +// Config holds the credential module configuration. +// +// The configuration is split into two layers, matching the authcore +// principle documented in docs/configuration.md: +// +// - The cryptographic layer is CLOSED and is not configurable here. Token +// entropy (32 bytes / 256 bits), the HMAC-SHA256 construction and its +// library-managed pepper, the constant-time comparison, the binding of +// purpose and subject into the stored hash, and the base64-URL token +// encoding are fixed. Weakening any of these produces a credential a +// stolen email can be spent against the wrong user or the wrong flow. +// - The policy layer is OPEN with today's value as the default: TTL +// (how long a token remains redeemable). +// +// What stays fixed regardless of configuration: +// +// - Token length: 32 random bytes (256 bits) per CSPRNG draw +// - Token encoding: base64 URL without padding, drops into a link as-is +// - Stored hash: HMAC-SHA256(pepper, len(purpose)||purpose || +// len(subject)||subject || len(token)||token), each length a big-endian +// uint32, so ("reset", "ab") cannot collide with ("reseta", "b") no +// matter what bytes the fields contain +// - Hash output: lowercase hex +// - Comparison: crypto/subtle.ConstantTimeCompare, with the comparison +// always run before the expiry check so wall-clock time does not reveal +// whether a token existed +// - Future issuedAt tolerance: 1 minute, anything more counts as expired +// +// Start from DefaultConfig and override only what your installation needs: +// +// cred, err := credential.New(auth) // defaults +// cred, err := credential.New(auth, credential.Config{TTL: 15 * time.Minute}) +type Config struct { + // TTL is how long a credential token remains valid from its issuedAt. + // A reset link that lives longer than a day is a standing key to the + // account sitting in an inbox, so validateConfig refuses anything above + // 24 hours. A TTL of zero or a negative TTL is refused for the same + // reason: an instantly-expired or backwards-running token has no + // legitimate use, only a bug. + // + // Defaults to 1 hour. + TTL time.Duration +} + +// DefaultConfig returns a Config with the library's recommended defaults. +func DefaultConfig() Config { + return Config{TTL: time.Hour} +} + +const maxTTL = 24 * time.Hour + +// applyDefaults is a pass-through for TTL. +// +// Unlike auth/totp's SkewSteps (a pointer so zero is a meaningful "no +// tolerance" value), TTL is a plain time.Duration: zero is not +// meaningful (it would make every token instantly expired), and the +// brief is explicit that it must be refused. Filling zero with the +// default here would silently turn a caller bug into a 1-hour token, +// so validateConfig is the only thing that decides what TTL values are +// allowed. New routes the no-Config case through DefaultConfig() so +// that callers who omit Config still get the 1-hour default; applyDefaults +// exists only so the function trio (DefaultConfig / applyDefaults / +// validateConfig) matches the shape used across the rest of authcore. +func applyDefaults(cfg Config) Config { + return cfg +} + +// validateConfig returns an error if cfg contains invalid values. +func validateConfig(cfg Config) error { + if cfg.TTL <= 0 { + return fmt.Errorf("ttl must be positive, got %s", cfg.TTL) + } + if cfg.TTL > maxTTL { + return fmt.Errorf("ttl must be at most %s, got %s", maxTTL, cfg.TTL) + } + return nil +} diff --git a/auth/credential/credential.go b/auth/credential/credential.go new file mode 100644 index 0000000..525adcc --- /dev/null +++ b/auth/credential/credential.go @@ -0,0 +1,257 @@ +// Package credential mints and verifies single-use credential tokens for +// authcore. +// +// # What it is for +// +// Password resets and account activations are the two most security +// sensitive emails an application ever sends. The token that powers +// each of them has to be high-entropy, single-use, time-bounded, and +// bound to the user and the flow it was minted for. Without that +// binding, a caller who keeps one table for both flows has a +// cross-flow confusion bug, and one who looks up by token rather than +// by user has an account mixup. This module makes those mistakes +// impossible from the credential side: it mints the token, hands the +// raw value back once for the email link and a keyed hash for the +// caller to store, and verifies a presented token against the stored +// hash. The module stores nothing. +// +// auth, _ := authcore.New(authcore.DefaultConfig()) +// cred, _ := credential.New(auth) +// +// // Issue: put Token in the email link, persist Hash and issuedAt. +// issued, _ := cred.Issue("reset", "alice@example.com") +// sendEmail(alice, "?token="+url.QueryEscape(issued.Token)) +// db.StoreResetToken(alice.ID, issued.Hash, time.Now()) +// +// // Verify: same purpose and subject, before TTL, with single-use. +// err := cred.Verify("reset", "alice@example.com", +// presented, storedHash, storedAt) +// switch { +// case errors.Is(err, credential.ErrExpired): +// return genericError() // "link invalid or expired" +// case errors.Is(err, credential.ErrInvalidCredential): +// return genericError() // same message; never reveal which it was +// case err != nil: +// return serverError() +// } +// db.DeleteResetToken(alice.ID) // single use, in the same transaction +// +// # What is fixed and what is open +// +// The cryptographic layer is closed: token entropy, HMAC-SHA256 with the +// library-managed pepper, base64-URL encoding, constant-time comparison, +// and the purpose || subject || token binding into the stored hash are +// all fixed. Weakening any of these produces a credential a stolen +// email can be spent against the wrong user or the wrong flow. +// +// The policy layer is open with secure defaults: the TTL (how long a +// token stays valid) is configurable within a 1-nanosecond-to-24-hour +// range, enforced by validateConfig. See docs/configuration.md for the +// principle. +package credential + +import ( + "crypto/hmac" + "crypto/rand" //nolint:gosec // CSPRNG draws for tokens + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "hash" + "time" + + "github.com/Glyndor/authcore" + "github.com/Glyndor/authcore/internal/clock" +) + +// Compile-time assertion: *Credential must satisfy authcore.Module. +var _ authcore.Module = (*Credential)(nil) + +const ( + tokenLen = 32 // 256 bits of CSPRNG output per token + futureSkew = time.Minute +) + +// Credential is the credential module. +// +// Construct one instance at application startup using New and share it +// across goroutines. Credential is safe for concurrent use after +// construction. +// +// It carries configuration only. Issue returns everything an issuance +// produces, so the module is safe to share across goroutines and never +// holds a raw token. +type Credential struct { + cfg Config + log authcore.Logger + secret []byte // HMAC-SHA256 pepper, sourced from the parent AuthCore + clock clock.Clock // injected; replaced by clock.Fixed in tests +} + +// The module holds no per-issue state on purpose. An earlier draft kept the +// most recent Token and Hash on this struct, which made two concurrent Issue +// calls a data race (the race detector flags it) and left the raw token, the +// one secret the caller must show exactly once, alive in memory for as long +// as the module. Issue returns everything the caller needs. + +// Issued is the result of Issue. +// +// Show Token to the user EXACTLY ONCE, typically by embedding it in a +// single-use email link. Persist Hash and the issuance timestamp together; +// pass them back to Verify at redemption time. The mapping between Token +// and Hash is 1:1 and irreversible: Hash cannot be inverted to recover +// Token. +type Issued struct { + // Token is the raw token. Put this in the email link; show it once. + Token string + // Hash is what the caller stores. It is a keyed HMAC-SHA256 hex digest + // that binds Token to the purpose and subject it was minted for. + Hash string +} + +// New creates a Credential module. +// +// cfg is optional. Omit it, or pass a zero-value Config, to apply the +// safe default (TTL=1 hour): +// +// cred, err := credential.New(auth) +// cred, err := credential.New(auth, credential.DefaultConfig()) +// cred, err := credential.New(auth, credential.Config{TTL: 15 * time.Minute}) +// +// The module reads the parent AuthCore's logger, refresh secret, and +// timezone; it generates no key material of its own. +func New(p authcore.Provider, cfg ...Config) (*Credential, error) { + var resolved Config + if len(cfg) > 0 { + resolved = applyDefaults(cfg[0]) + } else { + resolved = DefaultConfig() + } + if err := validateConfig(resolved); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err) + } + + c := &Credential{ + cfg: resolved, + log: p.Logger(), + secret: p.Keys().RefreshSecret(), + clock: clock.New(p.Config().Timezone), + } + c.log.Info("credential: module initialised (ttl=%s)", resolved.TTL) + return c, nil +} + +// Name returns the module's unique identifier. It implements authcore.Module. +func (c *Credential) Name() string { return "credential" } + +// Issue mints a fresh credential token bound to purpose and subject, and +// returns the raw token (for the email link) alongside the hash (for +// storage). Both purpose and subject are required, because an unbound +// token is +// the failure this module exists to prevent. +// +// The returned Issued is the only place the raw token appears. The module +// keeps no copy: it is safe to call from several goroutines at once, and +// the token does not outlive what the caller does with it. +// +// Errors: +// +// credential.ErrEmptyPurpose - purpose is "" +// credential.ErrEmptySubject - subject is "" +func (c *Credential) Issue(purpose, subject string) (*Issued, error) { + if purpose == "" { + return nil, ErrEmptyPurpose + } + if subject == "" { + return nil, ErrEmptySubject + } + + tokenBytes := make([]byte, tokenLen) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, fmt.Errorf("credential: generate token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(tokenBytes) + hash := c.computeHash(purpose, subject, token) + + c.log.Debug("credential: issued (purpose=%q, subject=%q)", purpose, subject) + + return &Issued{Token: token, Hash: hash}, nil +} + +// Verify checks a presented token against a stored hash for the given +// purpose and subject, with expiry enforced against issuedAt. +// +// purpose and subject must be the same strings that were passed to Issue. +// A mismatched purpose or subject produces a different hash and therefore +// a clean failure, so the module cannot redeem a "reset" token as an +// "activate" token, nor one for alice@example.com as one for bob@example.com. +// +// issuedAt must be the timestamp the caller stored alongside the hash at +// Issue time. The token is rejected as expired when: +// +// - clock.Now() is more than Config.TTL past issuedAt, or +// - issuedAt is more than one minute in the future (a backwards-running +// caller clock must not extend a token's life). +// +// Errors: +// +// credential.ErrInvalidCredential - token does not match the stored hash +// credential.ErrExpired - hash matched but issuedAt is outside +// the TTL window +// +// The caller MUST return the same generic message ("link invalid or +// expired") for both errors. Distinguishing them tells an attacker that a +// token existed. Compare, then check expiry; both run on every call so +// wall-clock time does not reveal whether the token was unknown. +func (c *Credential) Verify(purpose, subject, token, storedHash string, issuedAt time.Time) error { + // Always recompute the hash and run the constant-time comparison, + // even if a later check would reject the call anyway. This is what + // keeps the wall-clock timing of Verify independent of whether the + // token existed. + candidate := c.computeHash(purpose, subject, token) + matched := subtle.ConstantTimeCompare([]byte(candidate), []byte(storedHash)) == 1 + + elapsed := c.clock.Now().Sub(issuedAt) + expired := elapsed > c.cfg.TTL || elapsed < -futureSkew + + if !matched { + return ErrInvalidCredential + } + if expired { + return ErrExpired + } + return nil +} + +// computeHash returns the keyed HMAC-SHA256 hex digest of purpose, subject +// and token, each length-prefixed with a big-endian uint32. +// +// The length prefix is what makes the encoding unambiguous, and it is not +// interchangeable with a separator byte. An earlier draft joined the fields +// with a 0x00 byte, which works only while no field can contain that byte, +// and a Go string can: (purpose="a\x00b", subject="c") and (purpose="a", +// subject="b\x00c") hashed identically, so a caller whose subject came from +// untrusted input could redeem a token minted for a different pair. That is +// the exact property this module exists to provide. Prefixing by length +// depends on no assumption about the contents. +// +// Both Issue and Verify call through here, so a mismatched purpose or +// subject at Verify time produces a different candidate hash and a clean +// comparison failure. +func (c *Credential) computeHash(purpose, subject, token string) string { + mac := hmac.New(sha256.New, c.secret) + writeField(mac, purpose) + writeField(mac, subject) + writeField(mac, token) + return hex.EncodeToString(mac.Sum(nil)) +} + +// writeField writes s to h prefixed by its length as a big-endian uint32. +func writeField(h hash.Hash, s string) { + var n [4]byte + binary.BigEndian.PutUint32(n[:], uint32(len(s))) + _, _ = h.Write(n[:]) + _, _ = h.Write([]byte(s)) +} diff --git a/auth/credential/credential_bind_test.go b/auth/credential/credential_bind_test.go new file mode 100644 index 0000000..96a9d0a --- /dev/null +++ b/auth/credential/credential_bind_test.go @@ -0,0 +1,274 @@ +package credential + +// Tests for the purpose/subject binding into the stored hash, the zero-byte +// separator that prevents collision attacks, the sentinel errors raised by +// Issue, the URL safety of the token encoding, and the per-call uniqueness +// of Issue. + +import ( + "encoding/base64" + "errors" + "net/url" + "testing" + "time" +) + +// ---- Purpose / subject binding --------------------------------------------- + +// TestIssue_DifferentPurposesDifferentHashes ensures the hash actually +// binds purpose. Two tokens minted for the same subject under different +// purposes must produce different hashes. +func TestIssue_DifferentPurposesDifferentHashes(t *testing.T) { + c := newCred(t) + a, _ := c.Issue("reset", "alice@example.com") + b, _ := c.Issue("activate", "alice@example.com") + if a.Token == b.Token { + t.Skip("two CSPRNG draws returned the same token; rerun") + } + if a.Hash == b.Hash { + t.Error("two tokens minted for different purposes produced the same hash") + } +} + +// TestIssue_DifferentSubjectsDifferentHashes ensures the hash actually +// binds subject. Two tokens minted for the same purpose under different +// subjects must produce different hashes. +func TestIssue_DifferentSubjectsDifferentHashes(t *testing.T) { + c := newCred(t) + a, _ := c.Issue("reset", "alice@example.com") + b, _ := c.Issue("reset", "bob@example.com") + if a.Hash == b.Hash { + t.Error("two tokens minted for different subjects produced the same hash") + } +} + +// TestVerify_WrongPurposeRejected is the cross-flow confusion guard. A +// token minted for one purpose must not verify when presented under +// another purpose, even with the correct hash for the other flow. +func TestVerify_WrongPurposeRejected(t *testing.T) { + c := newCred(t) + reset, _ := c.Issue("reset", "alice@example.com") + activate, _ := c.Issue("activate", "alice@example.com") + + // Presenting the reset token under "activate" must fail. The hash + // the caller would have stored for an activate token is activate.Hash; + // presenting reset.Token with activate.Hash under "activate" is what + // this test asserts. + if err := c.Verify("activate", "alice@example.com", reset.Token, activate.Hash, epoch); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("cross-purpose verify: got %v, want ErrInvalidCredential", err) + } + + // And presenting the activate token under "reset" must also fail. + if err := c.Verify("reset", "alice@example.com", activate.Token, reset.Hash, epoch); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("reverse cross-purpose verify: got %v, want ErrInvalidCredential", err) + } +} + +// TestVerify_WrongSubjectRejected is the account mixup guard. A token +// minted for one subject must not verify when presented under a +// different subject. +func TestVerify_WrongSubjectRejected(t *testing.T) { + c := newCred(t) + a, _ := c.Issue("reset", "alice@example.com") + b, _ := c.Issue("reset", "bob@example.com") + + if err := c.Verify("reset", "bob@example.com", a.Token, b.Hash, epoch); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("cross-subject verify: got %v, want ErrInvalidCredential", err) + } + if err := c.Verify("reset", "alice@example.com", b.Token, a.Hash, epoch); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("reverse cross-subject verify: got %v, want ErrInvalidCredential", err) + } +} + +// TestSeparator_NoCollisionBetweenAdjacentFields is the structural +// guarantee that the zero byte prevents ("reset", "ab") from colliding +// with ("reseta", "b"). It computes the hash of the same token twice +// under those two pairings and asserts they differ. +func TestSeparator_NoCollisionBetweenAdjacentFields(t *testing.T) { + c := newCred(t) + const fixedToken = "abcd1234-fixed-token-for-separator-test" + h1 := c.computeHash("a", "bc", fixedToken) + h2 := c.computeHash("ab", "c", fixedToken) + if h1 == h2 { + t.Error("separator collision: (\"a\",\"bc\") and (\"ab\",\"c\") produced the same hash") + } +} + +// TestSeparator_NoCollisionAcrossPurposeSubject is the broader version +// of the same guarantee: a token presented with adjacent-purpose/ +// subject payloads must not match the hash of a token minted for any +// of the obvious confusion pairings. +func TestSeparator_NoCollisionAcrossPurposeSubject(t *testing.T) { + c := newCred(t) + const fixedToken = "fixed-token-value-1234567890" + cases := []struct { + mint [2]string + present [2]string + }{ + {[2]string{"reset", "alice"}, [2]string{"reset", "alice"}}, // sanity + {[2]string{"reset", "ab"}, [2]string{"reseta", "b"}}, // boundary + {[2]string{"reset", ""}, [2]string{"rese", "t"}}, // empty subject adjacent + {[2]string{"activate", "x"}, [2]string{"activatex", ""}}, // empty subject adjacent 2 + {[2]string{"p", "q"}, [2]string{"pq", ""}}, // short form + } + for _, tc := range cases { + mintHash := c.computeHash(tc.mint[0], tc.mint[1], fixedToken) + presentHash := c.computeHash(tc.present[0], tc.present[1], fixedToken) + // The sanity case is the only one where hashes must agree. + if tc.mint == tc.present { + if mintHash != presentHash { + t.Errorf("sanity: %v hash mismatch", tc.mint) + } + continue + } + if mintHash == presentHash { + t.Errorf("separator collision: mint=%v present=%v hash=%s", tc.mint, tc.present, mintHash) + } + } +} + +// ---- Sentinel errors at Issue time ----------------------------------------- + +func TestIssue_EmptyPurposeRejected(t *testing.T) { + c := newCred(t) + _, err := c.Issue("", "alice@example.com") + if !errors.Is(err, ErrEmptyPurpose) { + t.Errorf("Issue(\"\", \"alice@example.com\") = %v, want ErrEmptyPurpose", err) + } +} + +func TestIssue_EmptySubjectRejected(t *testing.T) { + c := newCred(t) + _, err := c.Issue("reset", "") + if !errors.Is(err, ErrEmptySubject) { + t.Errorf("Issue(\"reset\", \"\") = %v, want ErrEmptySubject", err) + } +} + +// TestIssue_BothEmptyReportsPurposeFirst pins the documented order: when +// both are empty, ErrEmptyPurpose is returned (it is checked first). +// A caller can rely on this when short-circuiting on either error. +func TestIssue_BothEmptyReportsPurposeFirst(t *testing.T) { + c := newCred(t) + _, err := c.Issue("", "") + if !errors.Is(err, ErrEmptyPurpose) { + t.Errorf("Issue(\"\", \"\") = %v, want ErrEmptyPurpose", err) + } +} + +// ---- URL safety ------------------------------------------------------------- + +// TestIssue_TokenIsURLSafe is the literal test from the brief: the raw +// token must round-trip through url.QueryEscape unchanged, because it +// goes into a query parameter in the email link without escaping. +func TestIssue_TokenIsURLSafe(t *testing.T) { + c := newCred(t) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + if got := url.QueryEscape(issued.Token); got != issued.Token { + t.Errorf("token not URL safe: QueryEscape(%q) = %q", issued.Token, got) + } +} + +// TestIssue_TokenIsRawBase64URL pins the encoding choice: the token is +// base64 URL without padding, never base32 (the brief explicitly +// excludes base32 because the token is in a URL, not on a printout). +func TestIssue_TokenIsRawBase64URL(t *testing.T) { + c := newCred(t) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + if _, err := base64.RawURLEncoding.DecodeString(issued.Token); err != nil { + t.Errorf("token is not base64 URL: %v", err) + } + // 32 bytes base64-URL-no-padding encodes to 43 characters (ceil(32*4/3) + // = 43, padding stripped because raw). + if len(issued.Token) != 43 { + t.Errorf("token length = %d, want 43 (32 raw bytes base64 URL)", len(issued.Token)) + } +} + +// TestIssue_TokenUsesURLAlphabet explicitly forbids characters that +// would force percent-encoding: '+', '/', '='. Raw base64 URL keeps +// only [A-Za-z0-9_-]. +func TestIssue_TokenUsesURLAlphabet(t *testing.T) { + c := newCred(t) + for i := 0; i < 64; i++ { + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue #%d: %v", i, err) + } + for _, c := range issued.Token { + ok := (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' + if !ok { + t.Fatalf("token contains non-URL-safe character %q in %q", c, issued.Token) + } + } + } + // Also exercise Verify with a token that came from a different Issue + // call, so the alphabet constraint must hold across many draws. + _ = time.Now() +} + +// ---- Uniqueness ------------------------------------------------------------- + +// TestIssue_UniqueTokensPerCall is the brute-force check that two Issue +// calls with identical arguments produce different tokens. With 256-bit +// tokens, a collision in two consecutive draws is cryptographically +// negligible; this test still exists to catch an accidental hard-coded +// token or a broken RNG swap. +func TestIssue_UniqueTokensPerCall(t *testing.T) { + c := newCred(t) + a, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("first Issue: %v", err) + } + b, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("second Issue: %v", err) + } + if a.Token == b.Token { + t.Errorf("two Issue calls with identical args produced the same token: %q", a.Token) + } +} + +// TestComputeHash_LengthPrefixSurvivesEmbeddedNULs is the reason the HMAC +// input is length-prefixed rather than separated by a byte. +// +// An earlier draft joined purpose, subject and token with a 0x00 separator, +// which disambiguates only while no field can contain that byte. A Go string +// can, so ("a\x00b", "c") and ("a", "b\x00c") hashed identically: a caller +// whose subject came from untrusted input could redeem a token minted for a +// different pair, which is exactly the binding this module exists to provide. +// +// If the construction is ever changed back to a separator, this fails. +func TestComputeHash_LengthPrefixSurvivesEmbeddedNULs(t *testing.T) { + c := newCred(t) + const token = "tok" + + cases := [][2]string{ + {"a\x00b", "c"}, + {"a", "b\x00c"}, + {"ab", "c"}, + {"a", "bc"}, + {"", "abc"}, + {"abc", ""}, + } + + seen := make(map[string][2]string, len(cases)) + for _, in := range cases { + h := c.computeHash(in[0], in[1], token) + if prev, dup := seen[h]; dup { + t.Errorf("collision: (%q, %q) and (%q, %q) hash to the same value", + prev[0], prev[1], in[0], in[1]) + continue + } + seen[h] = in + } +} diff --git a/auth/credential/credential_config_test.go b/auth/credential/credential_config_test.go new file mode 100644 index 0000000..99a6472 --- /dev/null +++ b/auth/credential/credential_config_test.go @@ -0,0 +1,90 @@ +package credential + +// Config validation tests. The brief calls out three specific rejection +// cases (zero TTL, negative TTL, 25 hours) plus a positive boundary +// (24 hours must be accepted). + +import ( + "errors" + "testing" + "time" +) + +// TestValidateConfig_Rejects covers the cases the brief lists verbatim: +// zero TTL, negative TTL, and 25 hours (one past the 24h cap). +func TestValidateConfig_Rejects(t *testing.T) { + cases := []struct { + name string + ttl time.Duration + }{ + {"zero", 0}, + {"negative", -time.Second}, + {"25 hours", 25 * time.Hour}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := validateConfig(Config{TTL: c.ttl}) + if err == nil { + t.Errorf("validateConfig(TTL=%s) = nil, want error", c.ttl) + } + }) + } +} + +// TestValidateConfig_Accepts covers the boundary that must be allowed: +// exactly 24 hours is at the cap, not past it. Anything past it has its +// own case above. +func TestValidateConfig_Accepts(t *testing.T) { + if err := validateConfig(Config{TTL: 24 * time.Hour}); err != nil { + t.Errorf("TTL=24h: validateConfig = %v, want nil", err) + } +} + +// TestValidateConfig_NanosecondFloor: the smallest positive TTL the brief +// allows. Below 1ns the value rounds to zero on some platforms, so this +// is the floor validateConfig must accept. +func TestValidateConfig_NanosecondFloor(t *testing.T) { + if err := validateConfig(Config{TTL: time.Nanosecond}); err != nil { + t.Errorf("TTL=1ns: validateConfig = %v, want nil", err) + } +} + +// TestNew_RejectsInvalidConfig: the public New path must wrap any +// validateConfig failure as ErrInvalidConfig so callers can distinguish +// startup errors from runtime errors. +func TestNew_RejectsInvalidConfig(t *testing.T) { + for _, ttl := range []time.Duration{0, -time.Second, 25 * time.Hour} { + _, err := New(newFakeProvider(t), Config{TTL: ttl}) + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("New(TTL=%s) = %v, want ErrInvalidConfig", ttl, err) + } + } +} + +// TestNew_AcceptsValidConfig: every value validateConfig accepts must be +// accepted by New too, including the 24-hour boundary. +func TestNew_AcceptsValidConfig(t *testing.T) { + for _, ttl := range []time.Duration{time.Nanosecond, time.Minute, time.Hour, 24 * time.Hour} { + if _, err := New(newFakeProvider(t), Config{TTL: ttl}); err != nil { + t.Errorf("New(TTL=%s) = %v, want nil", ttl, err) + } + } +} + +// TestApplyDefaults_IsPassThrough pins the "TTL is not a pointer" lesson +// from the brief: applyDefaults does NOT fill zero TTL with the default, +// because zero TTL is rejected by validateConfig as a meaningless value. +// Filling it would silently turn a caller bug into a 1-hour token, exactly +// the failure mode the brief exists to prevent. The 1-hour default is +// reached via the no-Config path in New, not via applyDefaults. +func TestApplyDefaults_IsPassThrough(t *testing.T) { + if got := applyDefaults(Config{}).TTL; got != 0 { + t.Errorf("applyDefaults(Config{}).TTL = %s, want 0 (zero must reach validateConfig, not be silently filled)", got) + } + if got := applyDefaults(DefaultConfig()).TTL; got != time.Hour { + t.Errorf("applyDefaults(DefaultConfig()).TTL = %s, want 1h", got) + } + if got := applyDefaults(Config{TTL: 30 * time.Minute}).TTL; got != 30*time.Minute { + t.Errorf("applyDefaults({30m}).TTL = %s, want 30m (explicit value must survive)", got) + } +} diff --git a/auth/credential/credential_fuzz_test.go b/auth/credential/credential_fuzz_test.go new file mode 100644 index 0000000..821818a --- /dev/null +++ b/auth/credential/credential_fuzz_test.go @@ -0,0 +1,61 @@ +package credential + +// Fuzz target for Verify. Verify accepts purpose, subject, token and +// storedHash from the network (or from the caller's storage), so every +// input is potentially adversarial. issuedAt is held at a fixed instant +// inside the TTL window so a nil result is never expected regardless of +// the string inputs - the only valid match would have to be the exact +// purpose, subject and token we minted ourselves, which the seed corpus +// covers in one case and the fuzzer cannot reach by construction. +// +// Verify must never panic and must never report a successful match for +// any input the module did not mint. Modeled on auth/totp/totp_fuzz_test.go. + +import ( + "testing" + + "github.com/Glyndor/authcore/internal/clock" +) + +func FuzzVerify(f *testing.F) { + mod, err := New(newFakeProvider(f)) + if err != nil { + f.Fatalf("credential.New: %v", err) + } + mod.clock = clock.Fixed(epoch) + + // Seed with adversarial inputs only. We do not seed a tuple that + // matches the Issue we just minted because the fuzz body treats + // any nil result as a failure - the only path to a nil result is + // a successful match against the freshly-minted (Token, Hash), + // which only happens for that specific tuple. The fuzzer cannot + // reconstruct it from random bytes. + enr, err := mod.Issue("reset", "alice@example.com") + if err != nil { + f.Fatalf("Issue: %v", err) + } + // Use the freshly-minted Token and Hash but with a mismatched + // (purpose, subject, hash) so every seed must fail. + f.Add("activate", "alice@example.com", enr.Token, enr.Hash) + f.Add("reset", "bob@example.com", enr.Token, enr.Hash) + f.Add("reset", "alice@example.com", "", "") + f.Add("", "", "", "") + f.Add("reset", "alice@example.com", "\x00\x00\x00", "deadbeef") + f.Add("reset", "alice@example.com", enr.Token, enr.Hash[:60]) // truncated hash + f.Add("reset", "alice@example.com", enr.Token, "") // empty stored hash + f.Add("reset", "alice@example.com", "short", enr.Hash) // wrong token + + f.Fuzz(func(t *testing.T, purpose, subject, token, storedHash string) { + // issuedAt is pinned to epoch (inside TTL) so the only path to + // a nil result is a successful match against enr.Hash, which + // only happens for the (reset, alice@example.com, enr.Token) + // tuple. The fuzzer cannot reconstruct that tuple from random + // bytes: it would need to break HMAC-SHA256 with the pepper + // this module was initialised with. + err := mod.Verify(purpose, subject, token, storedHash, epoch) + if err == nil { + t.Fatalf("Verify accepted adversarial input (purpose=%q subject=%q token=%q hash=%q)", + purpose, subject, token, storedHash) + } + }) +} diff --git a/auth/credential/credential_test.go b/auth/credential/credential_test.go new file mode 100644 index 0000000..9702972 --- /dev/null +++ b/auth/credential/credential_test.go @@ -0,0 +1,163 @@ +package credential + +// Shared test infrastructure for the credential package. The package-internal +// test scope (package credential rather than credential_test) lets the suite +// replace the module's clock with clock.Fixed so TTL and expiry assertions +// run deterministically without real sleeps. Same pattern as auth/totp. + +import ( + "crypto/ed25519" + "crypto/rand" + "sync" + "testing" + "time" + + "github.com/Glyndor/authcore" + "github.com/Glyndor/authcore/internal/clock" +) + +// ---- test doubles ----------------------------------------------------------- + +type fakeKeys struct{ secret []byte } + +func (fakeKeys) PrivateKey() ed25519.PrivateKey { return nil } +func (fakeKeys) PublicKey() ed25519.PublicKey { return nil } +func (k fakeKeys) RefreshSecret() []byte { return k.secret } +func (fakeKeys) KeyID() string { return "test" } + +type fakeProvider struct{ keys authcore.Keys } + +func (fakeProvider) Config() authcore.Config { return authcore.DefaultConfig() } +func (fakeProvider) Logger() authcore.Logger { return silentLogger{} } +func (p fakeProvider) Keys() authcore.Keys { return p.keys } + +type silentLogger struct{} + +func (silentLogger) Debug(string, ...any) {} +func (silentLogger) Info(string, ...any) {} +func (silentLogger) Warn(string, ...any) {} +func (silentLogger) Error(string, ...any) {} + +func newFakeProvider(tb testing.TB) fakeProvider { + tb.Helper() + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + tb.Fatalf("generate test HMAC secret: %v", err) + } + return fakeProvider{keys: fakeKeys{secret: secret}} +} + +// epoch is a fixed reference time used across tests that need a known +// "now" without sleeping. Picked well inside the year-292277396-safe +// range so future-skew tests stay clean. +var epoch = time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + +// newCred builds a Credential with a fixed clock pinned to epoch. Tests +// that need a different clock override c.clock directly. +func newCred(tb testing.TB, cfg ...Config) *Credential { + tb.Helper() + mod, err := New(newFakeProvider(tb), cfg...) + if err != nil { + tb.Fatalf("credential.New: %v", err) + } + mod.clock = clock.Fixed(epoch) + return mod +} + +// ---- Name / default config -------------------------------------------------- + +func TestName(t *testing.T) { + if got := newCred(t).Name(); got != "credential" { + t.Errorf("Name() = %q, want credential", got) + } +} + +func TestNew_DefaultConfigSucceeds(t *testing.T) { + if _, err := New(newFakeProvider(t)); err != nil { + t.Errorf("New() with default config returned error: %v", err) + } +} + +// TestNew_SatisfiesModule is the compile-time-equivalent runtime assertion. +// The var _ authcore.Module = (*Credential)(nil) line at the top of +// credential.go already proves it at build time; this test exists so the +// behaviour is named. +func TestNew_SatisfiesModule(t *testing.T) { + var m authcore.Module = newCred(t) + if m.Name() != "credential" { + t.Errorf("module Name() = %q, want credential", m.Name()) + } +} + +// ---- Issue / Verify happy path --------------------------------------------- + +// TestIssue_ReturnsIssuedWithTokenAndHash pins the result shape: Token is +// non-empty, Hash is non-empty, and Hash is a 64-char lowercase hex string +// (HMAC-SHA256 output). +func TestIssue_ReturnsIssuedWithTokenAndHash(t *testing.T) { + c := newCred(t) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + if issued.Token == "" { + t.Error("issued.Token is empty") + } + if issued.Hash == "" { + t.Error("issued.Hash is empty") + } + if len(issued.Hash) != 64 { + t.Errorf("issued.Hash length = %d, want 64 (hex SHA-256)", len(issued.Hash)) + } +} + +// TestIssue_HoldsNoPerIssueState pins the absence of a side effect that an +// earlier draft had: Issue also wrote the token and hash onto the module +// receiver. That made two concurrent Issue calls a data race, and it kept +// the raw token, the one value the caller must show exactly once, alive in +// memory for the lifetime of the module. Run under -race, fifty concurrent +// issues must be clean and every token distinct. +func TestIssue_HoldsNoPerIssueState(t *testing.T) { + c := newCred(t) + + const n = 50 + tokens := make([]string, n) + var wg sync.WaitGroup + for i := range tokens { + wg.Add(1) + go func(i int) { + defer wg.Done() + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Errorf("Issue: %v", err) + return + } + tokens[i] = issued.Token + }(i) + } + wg.Wait() + + seen := make(map[string]struct{}, n) + for i, tok := range tokens { + if tok == "" { + t.Fatalf("token %d is empty", i) + } + if _, dup := seen[tok]; dup { + t.Fatalf("token %d repeated: Issue is not drawing fresh randomness", i) + } + seen[tok] = struct{}{} + } +} + +// TestVerify_RoundTrip is the happy path: Issue, then Verify with the +// same purpose, subject, token, hash, and issuedAt==now, must succeed. +func TestVerify_RoundTrip(t *testing.T) { + c := newCred(t) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch); err != nil { + t.Errorf("Verify round trip failed: %v", err) + } +} diff --git a/auth/credential/credential_ttl_test.go b/auth/credential/credential_ttl_test.go new file mode 100644 index 0000000..7359a8c --- /dev/null +++ b/auth/credential/credential_ttl_test.go @@ -0,0 +1,158 @@ +package credential + +// TTL and expiry tests. The clock is replaced via c.clock = clock.Fixed(...) +// so every test in this file is deterministic without sleeping. + +import ( + "errors" + "testing" + "time" + + "github.com/Glyndor/authcore/internal/clock" +) + +// TestVerify_WithinTTLSucceeds covers the happy path with non-zero elapsed +// time: the token must still verify as long as elapsed <= TTL. +func TestVerify_WithinTTLSucceeds(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + // Pin the clock to issuedAt + TTL/2: well inside the window. + c.clock = clock.Fixed(epoch.Add(30 * time.Minute)) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch); err != nil { + t.Errorf("verify at TTL/2: %v", err) + } + // And at exactly TTL (boundary inclusive on the inside). + c.clock = clock.Fixed(epoch.Add(time.Hour)) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch); err != nil { + t.Errorf("verify at exactly TTL: %v", err) + } +} + +// TestVerify_OneNanosecondPastTTLExpires is the exact failure bound the +// brief calls out: a token issued for 1 hour, verified one nanosecond +// past that hour, must return ErrExpired. Driven by a fixed clock, no +// real sleep. +func TestVerify_OneNanosecondPastTTLExpires(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + c.clock = clock.Fixed(epoch.Add(time.Hour + time.Nanosecond)) + err = c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch) + if !errors.Is(err, ErrExpired) { + t.Errorf("1ns past TTL: got %v, want ErrExpired", err) + } +} + +// TestVerify_FarFutureExpires covers the future-leeway guard: an +// issuedAt more than a minute in the future is treated as expired +// (the brief's "clock running backwards must not extend a token's +// life"). Two minutes future, well past the one-minute skew window. +func TestVerify_FarFutureExpires(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + c.clock = clock.Fixed(epoch) + future := epoch.Add(2 * time.Minute) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, future); !errors.Is(err, ErrExpired) { + t.Errorf("issuedAt 2min in the future: got %v, want ErrExpired", err) + } +} + +// TestVerify_JustInsideFutureSkewSucceeds is the converse: an issuedAt +// within the 1-minute future-skew window must still verify. The window +// is "leeway", not "rejection". +func TestVerify_JustInsideFutureSkewSucceeds(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + c.clock = clock.Fixed(epoch) + // 30s in the future is inside the 1-minute skew window. + future := epoch.Add(30 * time.Second) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, future); err != nil { + t.Errorf("issuedAt 30s in the future: %v", err) + } +} + +// TestVerify_OneMinutePastFutureSkewExpires pins the exact boundary: the +// leeway is exactly 1 minute; 1 minute + 1 nanosecond in the future is +// past it. +func TestVerify_OneMinutePastFutureSkewExpires(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + c.clock = clock.Fixed(epoch) + future := epoch.Add(time.Minute + time.Nanosecond) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, future); !errors.Is(err, ErrExpired) { + t.Errorf("issuedAt 1min+1ns in the future: got %v, want ErrExpired", err) + } +} + +// TestVerify_ExpiredTokenTakesPriorityOverMatch documents that when both +// checks would fire, the function still returns ErrInvalidCredential for +// the wrong-hash case and ErrExpired for the right-hash-but-too-old +// case. This is the "same generic message to the user" hook the doc +// requires. +func TestVerify_ExpiredTokenTakesPriorityOverMatch(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + c.clock = clock.Fixed(epoch.Add(2 * time.Hour)) + + // Right hash, past TTL -> ErrExpired. + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch); !errors.Is(err, ErrExpired) { + t.Errorf("right-hash + past-TTL: got %v, want ErrExpired", err) + } + // Wrong hash, past TTL -> ErrInvalidCredential (match fails first). + if err := c.Verify("reset", "alice@example.com", issued.Token, "deadbeef", epoch); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("wrong-hash + past-TTL: got %v, want ErrInvalidCredential", err) + } +} + +// TestVerify_ZeroIssuedAtExpires protects callers who forget to store +// issuedAt. The zero time is roughly year 1, so every Verify sees it +// as billions of years past expiry. That is the correct outcome; the +// caller must store issuedAt. +func TestVerify_ZeroIssuedAtExpires(t *testing.T) { + c := newCred(t, Config{TTL: time.Hour}) + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + err = c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, time.Time{}) + if !errors.Is(err, ErrExpired) { + t.Errorf("zero issuedAt: got %v, want ErrExpired", err) + } +} + +// TestVerify_DefaultTTLMatchesDefaultConfig guards the documented +// default: a Credential built with no Config gets a 1-hour TTL. +func TestVerify_DefaultTTLMatchesDefaultConfig(t *testing.T) { + c := newCred(t) // no Config + issued, err := c.Issue("reset", "alice@example.com") + if err != nil { + t.Fatalf("Issue: %v", err) + } + // Just inside the default 1-hour window. + c.clock = clock.Fixed(epoch.Add(59 * time.Minute)) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch); err != nil { + t.Errorf("default TTL: verify at 59m failed: %v", err) + } + // Past it. + c.clock = clock.Fixed(epoch.Add(61 * time.Minute)) + if err := c.Verify("reset", "alice@example.com", issued.Token, issued.Hash, epoch); !errors.Is(err, ErrExpired) { + t.Errorf("default TTL: verify at 61m: got %v, want ErrExpired", err) + } +} diff --git a/auth/credential/errors.go b/auth/credential/errors.go new file mode 100644 index 0000000..4eb75c3 --- /dev/null +++ b/auth/credential/errors.go @@ -0,0 +1,53 @@ +package credential + +import "errors" + +// Sentinel errors returned by the credential package. +// Use errors.Is to check for these in calling code. +var ( + // ErrInvalidConfig is returned by New when the provided Config fails + // validation (e.g. a zero, negative, or oversized TTL). + // + // Safety: INTERNAL — a startup/programming error. Treat as a 500. + ErrInvalidConfig = errors.New("credential: invalid configuration") + + // ErrInvalidCredential is returned by Verify when the presented token does + // not match the stored hash under the given purpose and subject. A token + // is the only thing the module ever stored alongside a hash, so a mismatch + // means either a wrong token or a token minted for a different purpose or + // subject. + // + // Safety: CLIENT-SAFE — return a generic "link invalid or expired" message + // to the user. The caller MUST show the same generic message for + // ErrInvalidCredential and ErrExpired because distinguishing them tells an + // attacker that a token existed. + ErrInvalidCredential = errors.New("credential: token does not match") + + // ErrExpired is returned by Verify when the token matched the stored hash + // but its issuedAt is too far in the past (beyond Config.TTL) or too far + // in the future (more than a minute past clock skew). Both are the same + // outcome to the user: the link is no longer redeemable. + // + // Safety: CLIENT-SAFE — return the same generic message as + // ErrInvalidCredential. The distinction is for logs and rate-limit + // accounting, never for the user-visible response. + ErrExpired = errors.New("credential: token has expired") + + // ErrEmptyPurpose is returned by Issue when the caller passes an empty + // purpose string. A token minted with an empty purpose would be unbound + // and could be redeemed against any flow, which is the failure this + // module exists to prevent. + // + // Safety: INTERNAL — a programming error in the calling handler. Do not + // echo the empty string back; log and return a generic error. + ErrEmptyPurpose = errors.New("credential: purpose must not be empty") + + // ErrEmptySubject is returned by Issue when the caller passes an empty + // subject string. A token minted with an empty subject would not be + // attributable to any user and would be a confused-deputy risk in any + // "look up by subject" storage. + // + // Safety: INTERNAL — a programming error in the calling handler. Do not + // echo the empty string back; log and return a generic error. + ErrEmptySubject = errors.New("credential: subject must not be empty") +) diff --git a/auth/field/config.go b/auth/field/config.go new file mode 100644 index 0000000..549138a --- /dev/null +++ b/auth/field/config.go @@ -0,0 +1,93 @@ +// Package field holds the configuration for the field-level encryption +// module. See field.go for the cryptography and the data flow; this file +// is the config surface only. +package field + +import "fmt" + +// Config holds the field module configuration. +// +// The configuration is split into two layers, matching the authcore +// principle documented in docs/configuration.md: +// +// - The cryptographic layer is CLOSED and is not configurable here. +// AES-256-GCM, the 12-byte random nonce per Encrypt, the HKDF-SHA256 +// derivation of the encryption and index keys from the library-managed +// refresh secret, the HMAC-SHA256 construction of the blind index, the +// length-prefixed binding of the context into both the AAD and the +// index, and the base64 encoding of the ciphertext are all fixed. +// Weakening any of these lets a stolen ciphertext or a guessed value +// recover data the column is meant to protect. +// - The policy layer is OPEN with one required field: Context, the name +// of the database column the module is protecting for this instance. +// +// What stays fixed regardless of configuration: +// +// - Encryption: AES-256-GCM with a 12-byte random nonce per call +// - Nonce source: crypto/rand (never derived from the plaintext, never +// counted; a repeated nonce under the same key destroys GCM) +// - Additional authenticated data: the bound Context, length-prefixed +// with a big-endian uint32 +// - Key derivation: HKDF-SHA256 from Keys().RefreshSecret(), with +// distinct info labels for the encryption key and the index key so +// the two constructions cannot share a weakness +// - Blind index: HMAC-SHA256(idxKey, len(context)||context || +// len(value)||value), each length a big-endian uint32, hex-encoded +// - Ciphertext encoding: base64.RawStdEncoding of nonce || sealed +// +// Start from Config and set Context before passing the value to New, +// since Context has no zero-value default and validateConfig refuses +// an empty one: +// +// fld, err := field.New(auth, field.Config{Context: "email"}) +type Config struct { + // Context names the field this instance protects. It is bound into + // the AES additional authenticated data and into the blind index + // input, so a ciphertext lifted from one column cannot be decrypted + // as another column, and a blind index computed for one field never + // matches another. + // + // Context has no default: a caller who does not name the field is + // telling the module nothing, and silently accepting "" would make + // every field share one keyspace. validateConfig refuses the empty + // string at New. + Context string +} + +// DefaultConfig returns a zero-value Config. The caller MUST set +// Context before passing the result to New; validateConfig refuses the +// empty string that flows from a forgotten assignment. +// +// fld, err := field.New(auth, field.DefaultConfig()) +// fld, err := field.New(auth, field.Config{Context: "email"}) +// +// Unlike auth/credential.DefaultConfig, which fills a safe policy value +// the caller can ignore, this module has no safe "unnamed field" +// value, so the default is the zero Config and validateConfig is the +// gate. The trio (DefaultConfig / applyDefaults / validateConfig) is +// kept in shape so the constructor wiring matches the rest of authcore. +func DefaultConfig() Config { + return Config{} +} + +// applyDefaults is a pass-through for the field module. +// +// Context is a plain string where "" is not meaningful: an empty +// Context would make every field share one keyspace, which is the +// exact failure this module exists to prevent. Filling "" with a +// default here would silently turn a caller bug into a single shared +// keyspace, so validateConfig is the only thing that decides what +// Context values are allowed. applyDefaults exists only so the +// function trio (DefaultConfig / applyDefaults / validateConfig) +// matches the shape used across the rest of authcore. +func applyDefaults(cfg Config) Config { + return cfg +} + +// validateConfig returns an error if cfg contains invalid values. +func validateConfig(cfg Config) error { + if cfg.Context == "" { + return fmt.Errorf("context must not be empty") + } + return nil +} diff --git a/auth/field/errors.go b/auth/field/errors.go new file mode 100644 index 0000000..bff7cd7 --- /dev/null +++ b/auth/field/errors.go @@ -0,0 +1,30 @@ +package field + +import "errors" + +// Sentinel errors returned by the field package. +// Use errors.Is to check for these in calling code. +var ( + // ErrInvalidConfig is returned by New when the provided Config fails + // validation (today: an empty Context). The brief is explicit that + // Context is not decoration: it is bound into both the AES additional + // authenticated data and the blind index, so a caller who does not + // name the field is telling the module nothing, and silently + // accepting "" would make every field share one keyspace. + // + // Safety: INTERNAL — a startup/programming error. Treat as a 500. + ErrInvalidConfig = errors.New("field: invalid configuration") + + // ErrDecrypt is returned by Decrypt for every failure mode: an + // input shorter than the nonce, an input that is not valid base64, + // and a failed GCM authentication tag. The three are not + // distinguished, because which one failed is information about the + // stored data and the caller has nothing useful to do differently. + // A row that does not decrypt is corrupt or never belonged to this + // column, and the response is the same either way. + // + // Safety: CLIENT-SAFE — the caller may surface a generic error + // ("could not read this row") to the user. Do not echo the input + // back, and do not log enough to recreate the ciphertext. + ErrDecrypt = errors.New("field: decryption failed") +) diff --git a/auth/field/field.go b/auth/field/field.go new file mode 100644 index 0000000..7fa0059 --- /dev/null +++ b/auth/field/field.go @@ -0,0 +1,302 @@ +// Package field provides field-level encryption for a single database +// column, plus a blind index so the value stays searchable by exact +// equality without being readable. +// +// # What it is for +// +// Storing a user's email address encrypted, and still enforcing one +// account per address, is the case that drives this module. The +// caller writes the ciphertext into a TEXT column, the blind index +// into another column, and a UNIQUE index on the blind index gives +// the uniqueness guarantee without the database ever holding the +// plaintext. Right now every application writes the same sixty lines +// of AES-GCM plumbing by hand, which is the "right size? right RNG? +// right nonce?" problem this module closes. +// +// The module does NOT normalise its input. Lowercasing, trimming and +// Unicode folding are the caller's job, and auth/email and +// auth/username already do it. A module that normalised silently +// would make BlindIndex disagree with whatever the caller stored: +// index the same form you store, every time, or lookups miss. +// +// fld, _ := field.New(auth, field.Config{Context: "email"}) +// +// // Write path: encrypt and produce the index the UNIQUE constraint +// // runs against. Normalise first; the module never touches case. +// plain := strings.ToLower(strings.TrimSpace(userInput)) +// ct, err := fld.Encrypt(plain) +// if err != nil { return serverError() } +// idx := fld.BlindIndex(plain) +// db.Exec(`INSERT INTO users (email_ct, email_idx) VALUES (?, ?) +// ON CONFLICT (email_idx) DO NOTHING`, ct, idx) +// +// // Read path: hash the candidate the same way, look up the row, +// // then decrypt. A hit in the blind index proves the ciphertext +// // came from a row that shared the same plaintext; a miss proves +// // it didn't. +// row := db.QueryRow(`SELECT email_ct FROM users WHERE email_idx = ?`, +// fld.BlindIndex(plain)) +// var ct string +// if err := row.Scan(&ct); err != nil { return notFound() } +// decrypted, err := fld.Decrypt(ct) +// if err != nil { return serverError() } +// +// # What is fixed and what is open +// +// The cryptographic layer is closed: AES-256-GCM, a 12-byte random +// nonce per Encrypt from crypto/rand, the HKDF-SHA256 derivation of +// the encryption and index keys from the library-managed refresh +// secret, the HMAC-SHA256 construction of the blind index, the +// length-prefixed binding of the configured Context into both the AES +// additional authenticated data and the blind index, and the +// base64-RawStdEncoding of nonce || sealed are all fixed. Weakening +// any of these lets a stolen ciphertext or a guessed value recover +// data the column is meant to protect. +// +// The policy layer is open with one required field: Context, the +// name of the database column the module is protecting for this +// instance. Context is not decoration; it is bound into the AAD +// and the index so a ciphertext from one column cannot be decrypted +// as another. +package field + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" //nolint:gosec // standard-library HKDF; sha256 below is per the protocol + "crypto/hmac" + "crypto/rand" //nolint:gosec // CSPRNG draws for nonces + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "hash" + + "github.com/Glyndor/authcore" +) + +// Compile-time assertion: *Field must satisfy authcore.Module. +var _ authcore.Module = (*Field)(nil) + +const ( + // nonceLen is the GCM standard nonce length. 12 bytes is the value + // the GCM spec is optimised for and the one the standard library's + // AES-GCM implementation expects by default. + nonceLen = 12 + // keyLen is the AES-256 key size in bytes. 32 bytes is the one the + // brief specifies and the one the derivation produces. + keyLen = 32 + // aeadTagLen is the size of the GCM authentication tag in bytes + // (the standard library's default). It is appended to the sealed + // payload and verified on Decrypt. + aeadTagLen = 16 +) + +// HKDF info labels. The version suffix is deliberate: if the +// derivation ever has to change, the old label stays available so +// existing rows remain decryptable. A new label for the new +// construction can sit alongside it in the same code. +const ( + encKeyInfo = "authcore/field/aes-256-gcm/v1" + idxKeyInfo = "authcore/field/blind-index/v1" +) + +// Field is the field-level encryption module. +// +// Construct one instance per column at application startup using New, +// and share it across goroutines. Field is safe for concurrent use +// after construction. +// +// It carries configuration and the two derived keys (the AES key and +// the index HMAC key). It holds no per-call state, so two concurrent +// Encrypt or Decrypt calls are independent and a fresh nonce is drawn +// on every Encrypt. +type Field struct { + cfg Config + log authcore.Logger + aead cipher.AEAD // AES-256-GCM bound to the derived encKey + idxKey []byte // HMAC-SHA256 key for BlindIndex + context []byte // bound Context as bytes, captured once for both AAD and the index +} + +// New creates a Field module. +// +// cfg is required. The single field, Context, is the name of the +// database column the module is protecting; an empty Context is +// rejected with ErrInvalidConfig because it would make every field +// share one keyspace: +// +// fld, err := field.New(auth, field.Config{Context: "email"}) +// fld, err := field.New(auth, field.Config{Context: "phone"}) +// +// The module derives its encryption and index keys from +// Keys().RefreshSecret() using HKDF-SHA256. It generates no key +// material of its own. +func New(p authcore.Provider, cfg ...Config) (*Field, error) { + var resolved Config + if len(cfg) > 0 { + resolved = applyDefaults(cfg[0]) + } else { + resolved = DefaultConfig() + } + if err := validateConfig(resolved); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err) + } + + encKey, err := deriveKey(p.Keys().RefreshSecret(), encKeyInfo) + if err != nil { + return nil, fmt.Errorf("field: derive encryption key: %w", err) + } + idxKey, err := deriveKey(p.Keys().RefreshSecret(), idxKeyInfo) + if err != nil { + return nil, fmt.Errorf("field: derive index key: %w", err) + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, fmt.Errorf("field: new AES cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("field: new GCM AEAD: %w", err) + } + + f := &Field{ + cfg: resolved, + log: p.Logger(), + aead: aead, + idxKey: idxKey, + context: []byte(resolved.Context), + } + f.log.Info("field: module initialised (context=%q)", resolved.Context) + return f, nil +} + +// Name returns the module's unique identifier. It implements +// authcore.Module. +func (f *Field) Name() string { return "field" } + +// Encrypt seals plaintext under the bound Context and returns a +// base64-encoded ciphertext. The output drops into a TEXT column as +// is; a BYTEA-style column can store the raw bytes by passing the +// string through base64.RawStdEncoding.DecodeString. +// +// A fresh 12-byte nonce is drawn from crypto/rand on every call, so +// encrypting the same plaintext twice produces different ciphertexts +// and the module never derives the nonce from the plaintext (a +// repeated nonce under the same key destroys GCM). +// +// The bound Context is fed to GCM as additional authenticated data, +// length-prefixed with a big-endian uint32 so the AAD is unambiguous +// no matter what bytes the Context contains. A ciphertext written +// for "email" cannot be decrypted as "phone" because the AAD will +// differ and GCM authentication will fail. +func (f *Field) Encrypt(plaintext string) (string, error) { + nonce := make([]byte, nonceLen) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("field: generate nonce: %w", err) + } + + aad := buildAAD(f.context) + sealed := f.aead.Seal(nonce, nonce, []byte(plaintext), aad) + + return base64.RawStdEncoding.EncodeToString(sealed), nil +} + +// Decrypt reverses Encrypt for a ciphertext that was produced under +// the same Context. It returns ErrDecrypt for every failure mode: an +// input shorter than the nonce, an input that is not valid base64, +// and a failed GCM authentication tag. The three are not +// distinguished, because which one failed is information about the +// stored data and the caller has nothing useful to do differently. +// +// Decrypt never panics, including on input shorter than the nonce: +// the base64 decoder would otherwise panic on a too-short slice. +func (f *Field) Decrypt(ciphertext string) (string, error) { + raw, err := base64.RawStdEncoding.DecodeString(ciphertext) + if err != nil { + return "", ErrDecrypt + } + if len(raw) < nonceLen+aeadTagLen { + // Must contain at least nonce + GCM tag; anything shorter + // cannot possibly be a valid sealed payload. Returning + // ErrDecrypt (not panicking) is the whole point of the + // test "Truncating the ciphertext to shorter than a nonce + // gives ErrDecrypt, not a panic or an index out of range". + return "", ErrDecrypt + } + + nonce := raw[:nonceLen] + sealed := raw[nonceLen:] + + aad := buildAAD(f.context) + plain, err := f.aead.Open(nil, nonce, sealed, aad) + if err != nil { + return "", ErrDecrypt + } + return string(plain), nil +} + +// BlindIndex returns a deterministic, fixed-size hex string for value +// under the bound Context. The caller passes the result to the +// database the same way it would pass a SHA-256 hash, and a UNIQUE +// index on the column enforces one row per value. +// +// The function never returns an error and never panics, because +// HMAC-SHA256 over a fixed-size key cannot fail at runtime. The +// caller MUST normalise value first (lowercase, trim, fold, etc.) +// and BlindIndex the same form it stores; a module that normalised +// silently would make BlindIndex disagree with whatever the caller +// stored, and lookups would miss. +// +// The output is hex(HMAC-SHA256(idxKey, len(context)||context || +// len(value)||value)), each length a big-endian uint32. The length +// prefix is what makes the encoding unambiguous: ("a", "bc") and +// ("ab", "c") cannot collide, and neither can ("email", "user@x") +// and ("emailuser", "@x"). A separator byte would only disambiguate +// while no field contained it, and a Go string can contain any byte. +func (f *Field) BlindIndex(value string) string { + mac := hmac.New(sha256.New, f.idxKey) + writeLengthPrefixed(mac, f.context) + writeLengthPrefixed(mac, []byte(value)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// deriveKey runs HKDF-SHA256 over the library-managed refresh secret +// with the given info label and returns 32 bytes. Two different +// info labels produce two independent keys, so the encryption key +// and the index key cannot share a weakness even though they are +// both derived from the same 32-byte secret. +func deriveKey(secret []byte, info string) ([]byte, error) { + r, err := hkdf.Key(sha256.New, secret, nil, info, keyLen) + if err != nil { + return nil, err + } + return r, nil +} + +// buildAAD returns the additional authenticated data: the bound +// Context length-prefixed with a big-endian uint32. AAD is the +// part of the input GCM authenticates but does not encrypt; the +// length prefix makes the encoding unambiguous no matter what +// bytes the Context contains. +func buildAAD(context []byte) []byte { + var n [4]byte + binary.BigEndian.PutUint32(n[:], uint32(len(context))) + aad := make([]byte, 0, 4+len(context)) + aad = append(aad, n[:]...) + aad = append(aad, context...) + return aad +} + +// writeLengthPrefixed writes b to h prefixed by its length as a +// big-endian uint32. The length prefix is what makes the +// concatenation unambiguous; a separator byte would only hold while +// no field contained it, and a Go string can contain any byte. +func writeLengthPrefixed(h hash.Hash, b []byte) { + var n [4]byte + binary.BigEndian.PutUint32(n[:], uint32(len(b))) + _, _ = h.Write(n[:]) + _, _ = h.Write(b) +} diff --git a/auth/field/field_bind_test.go b/auth/field/field_bind_test.go new file mode 100644 index 0000000..68bd22e --- /dev/null +++ b/auth/field/field_bind_test.go @@ -0,0 +1,266 @@ +package field + +// Tests for the Context binding into both the AES AAD and the blind +// index, the length-prefixed non-collision guarantee, and the +// cross-context uniqueness of ciphertexts and indexes. The shape +// mirrors auth/credential/credential_bind_test.go. + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "errors" + "testing" + + "github.com/Glyndor/authcore" +) + +// sharedProvider returns a provider whose keys are pinned so two +// Field instances share the same root secret. Used to assert that +// "the same value, two different contexts" behaves as the brief +// requires without the test also having to fight different secrets. +func sharedProvider(tb testing.TB) authcore.Provider { + tb.Helper() + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + tb.Fatalf("generate shared secret: %v", err) + } + return sharedProviderWith(secret) +} + +type sharedKeys struct{ secret []byte } + +func (sharedKeys) PrivateKey() ed25519.PrivateKey { return nil } +func (sharedKeys) PublicKey() ed25519.PublicKey { return nil } +func (k sharedKeys) RefreshSecret() []byte { return k.secret } +func (sharedKeys) KeyID() string { return "test" } + +// sharedProviderWith wraps a fixed secret in a Provider that satisfies +// the authcore.Provider interface used by the field module's tests. +func sharedProviderWith(secret []byte) authcore.Provider { + return &testProvider{keys: sharedKeys{secret: secret}} +} + +type testProvider struct{ keys sharedKeys } + +func (*testProvider) Config() authcore.Config { return authcore.DefaultConfig() } +func (*testProvider) Logger() authcore.Logger { return silentLogger{} } +func (p *testProvider) Keys() authcore.Keys { return p.keys } + +// ---- Context binding (AAD) -------------------------------------------------- + +// TestCrossContext_DecryptFailsAcrossContexts is the AAD binding +// test. A ciphertext produced under "email" must not decrypt under +// a module built with "phone", because the AAD differs and GCM +// authentication fails. The test passes the ciphertext from the +// email module to the phone module, which is the exact swap a +// confused-caller bug would do. +func TestCrossContext_DecryptFailsAcrossContexts(t *testing.T) { + p := sharedProvider(t) + email, err := New(p, Config{Context: "email"}) + if err != nil { + t.Fatalf("New email: %v", err) + } + phone, err := New(p, Config{Context: "phone"}) + if err != nil { + t.Fatalf("New phone: %v", err) + } + + ct, err := email.Encrypt("alice@example.com") + if err != nil { + t.Fatalf("email.Encrypt: %v", err) + } + if _, err := phone.Decrypt(ct); !errors.Is(err, ErrDecrypt) { + t.Errorf("phone.Decrypt(email ct): got %v, want ErrDecrypt", err) + } +} + +// TestCrossContext_DifferentCiphertextsForSamePlaintext asserts the +// same swap from the other side: two modules with different contexts +// must produce different ciphertexts for the same plaintext, and +// neither can read the other's. This is the test the brief lists +// as "Two modules built with different contexts from the same +// provider produce different ciphertexts for the same plaintext, +// and neither can read the other's." +func TestCrossContext_DifferentCiphertextsForSamePlaintext(t *testing.T) { + p := sharedProvider(t) + email, _ := New(p, Config{Context: "email"}) + phone, _ := New(p, Config{Context: "phone"}) + + const plain = "alice@example.com" + ctEmail, err := email.Encrypt(plain) + if err != nil { + t.Fatalf("email.Encrypt: %v", err) + } + ctPhone, err := phone.Encrypt(plain) + if err != nil { + t.Fatalf("phone.Encrypt: %v", err) + } + if ctEmail == ctPhone { + t.Fatal("two modules with different contexts produced the same ciphertext for the same plaintext") + } + + if _, err := phone.Decrypt(ctEmail); !errors.Is(err, ErrDecrypt) { + t.Errorf("phone.Decrypt(email ct): got %v, want ErrDecrypt", err) + } + if _, err := email.Decrypt(ctPhone); !errors.Is(err, ErrDecrypt) { + t.Errorf("email.Decrypt(phone ct): got %v, want ErrDecrypt", err) + } +} + +// ---- Context binding (blind index) ------------------------------------------ + +// TestBlindIndex_DeterministicAcrossCalls: BlindIndex of the same +// value under the same module must be stable. The brief is explicit +// that the function returns a string and no error. +func TestBlindIndex_DeterministicAcrossCalls(t *testing.T) { + f := newFld(t, "email") + a := f.BlindIndex("alice@example.com") + b := f.BlindIndex("alice@example.com") + if a != b { + t.Errorf("BlindIndex not deterministic: %s vs %s", a, b) + } + if len(a) != 64 { + t.Errorf("BlindIndex length = %d, want 64 (hex SHA-256)", len(a)) + } +} + +// TestBlindIndex_DeterministicAcrossModules: two modules built from +// the same provider and context must produce the same blind index +// for the same value. This is what makes "store index, look up by +// index" work across restarts and across instances. +func TestBlindIndex_DeterministicAcrossModules(t *testing.T) { + p := sharedProvider(t) + a, _ := New(p, Config{Context: "email"}) + b, _ := New(p, Config{Context: "email"}) + + idxA := a.BlindIndex("alice@example.com") + idxB := b.BlindIndex("alice@example.com") + if idxA != idxB { + t.Errorf("BlindIndex across same-config modules differs: %s vs %s", idxA, idxB) + } +} + +// TestBlindIndex_DiffersAcrossContexts: the same value under two +// different contexts must produce different indexes. The Context is +// bound into the HMAC input, so "email" and "phone" cannot share an +// index space. +func TestBlindIndex_DiffersAcrossContexts(t *testing.T) { + p := sharedProvider(t) + email, _ := New(p, Config{Context: "email"}) + phone, _ := New(p, Config{Context: "phone"}) + + const value = "alice@example.com" + if email.BlindIndex(value) == phone.BlindIndex(value) { + t.Errorf("BlindIndex of %q under different contexts matched", value) + } +} + +// ---- Length-prefix non-collision -------------------------------------------- + +// TestLengthPrefix_NoCollisionBetweenAdjacentFields is the structural +// guarantee that the length prefix prevents ("a", "bc") from colliding +// with ("ab", "c"). The cases are chosen so a separator-byte +// implementation would collide and the length-prefixed one does not. +// "a||bc" = 0x61 0x62 0x63; "ab||c" = 0x61 0x62 0x63. A separator +// would fold them; the length prefix does not. +func TestLengthPrefix_NoCollisionBetweenAdjacentFields(t *testing.T) { + p := sharedProvider(t) + // One context, two different splits of the same byte sequence. + a, _ := New(p, Config{Context: "a"}) + ab, _ := New(p, Config{Context: "ab"}) + + const value = "c" // the "value" is the same, only the context splits + // a + "bc" vs "ab" + "c": the bytes inside the HMAC are the same, + // but the framing differs. A separator would collapse them; a + // length prefix does not. + if a.BlindIndex("bc") == ab.BlindIndex(value) { + t.Error("length prefix collision: BlindIndex(\"bc\") under context \"a\" matched BlindIndex(\"c\") under context \"ab\"") + } +} + +// TestLengthPrefix_NoCollisionBetweenContextAndValue is the broader +// version of the same guarantee. We choose a single context "email" +// and a single value "user@x", and another arrangement where the +// combined bytes are the same but the boundary is in a different +// place. A length prefix keeps them apart; a separator would not. +func TestLengthPrefix_NoCollisionBetweenContextAndValue(t *testing.T) { + p := sharedProvider(t) + // context "a", value "bc" -> bytes a, b, c + a, _ := New(p, Config{Context: "a"}) + // context "ab", value "c" -> bytes a, b, c + ab, _ := New(p, Config{Context: "ab"}) + + if a.BlindIndex("bc") == ab.BlindIndex("c") { + t.Error("length prefix collision: (\"a\",\"bc\") matched (\"ab\",\"c\")") + } +} + +// TestLengthPrefix_NoCollisionSameContextSameValue is the trivial +// sanity check: the same value under the same context must produce +// the same index (already covered above, but listed here for the +// "all four quadrants of the collision matrix" symmetry). +func TestLengthPrefix_NoCollisionSameContextSameValue(t *testing.T) { + f := newFld(t, "email") + if f.BlindIndex("alice@example.com") != f.BlindIndex("alice@example.com") { + t.Error("same context, same value produced different indexes") + } +} + +// TestBlindIndex_DifferentValuesDifferentIndexes is the obvious +// "different input, different output" check. The HMAC is not +// guaranteed to be collision-free in principle, but for the +// 32-byte hex output a near-collision from two short strings is +// not reachable by a test seed. +func TestBlindIndex_DifferentValuesDifferentIndexes(t *testing.T) { + f := newFld(t, "email") + if f.BlindIndex("alice@example.com") == f.BlindIndex("bob@example.com") { + t.Error("different values produced the same blind index") + } +} + +// TestDeriveKey_SeparatesTheTwoKeysFromEachOtherAndFromTheMaster pins key +// separation, which no behavioural test can see. +// +// Encrypt, Decrypt and BlindIndex all keep working if deriveKey is removed +// and Keys().RefreshSecret() is used directly for both the AES key and the +// index key. Round trips still round trip and indexes still match, so the +// whole suite stays green while one secret is doing three jobs and a +// weakness in any one construction reaches the others. +// +// This is the assertion that goes red instead. +func TestDeriveKey_SeparatesTheTwoKeysFromEachOtherAndFromTheMaster(t *testing.T) { + t.Parallel() + + master := newFakeProvider(t).Keys().RefreshSecret() + + encKey, err := deriveKey(master, encKeyInfo) + if err != nil { + t.Fatalf("deriveKey(encKeyInfo): %v", err) + } + idxKey, err := deriveKey(master, idxKeyInfo) + if err != nil { + t.Fatalf("deriveKey(idxKeyInfo): %v", err) + } + + for name, key := range map[string][]byte{"encKey": encKey, "idxKey": idxKey} { + if len(key) != keyLen { + t.Errorf("%s is %d bytes, want %d", name, len(key), keyLen) + } + if bytes.Equal(key, master) { + t.Errorf("%s is the master secret verbatim: the derivation was skipped", name) + } + } + if bytes.Equal(encKey, idxKey) { + t.Error("encKey and idxKey are the same value: both info labels derive one key") + } + + // Derivation must be deterministic, or a restart would lose every row. + again, err := deriveKey(master, encKeyInfo) + if err != nil { + t.Fatalf("deriveKey again: %v", err) + } + if !bytes.Equal(encKey, again) { + t.Error("deriveKey is not deterministic: existing ciphertexts would not decrypt after a restart") + } +} diff --git a/auth/field/field_config_test.go b/auth/field/field_config_test.go new file mode 100644 index 0000000..a80ec02 --- /dev/null +++ b/auth/field/field_config_test.go @@ -0,0 +1,76 @@ +package field + +// Config validation tests. The brief calls out exactly one rejection +// case (empty Context) and the public New path wrapping it as +// ErrInvalidConfig. The shape mirrors auth/credential/config_test.go. + +import ( + "errors" + "testing" +) + +// TestValidateConfig_RejectsEmpty pins the only rejection case: a +// Context of "" must fail validateConfig. The brief is explicit that +// there is no default for Context. +func TestValidateConfig_RejectsEmpty(t *testing.T) { + if err := validateConfig(Config{Context: ""}); err == nil { + t.Error("validateConfig(Context=\"\") = nil, want error") + } +} + +// TestValidateConfig_Accepts pins the only acceptance boundary: +// any non-empty Context is allowed. The brief says Context names the +// field; the module does not constrain what the name is. +func TestValidateConfig_Accepts(t *testing.T) { + for _, ctx := range []string{"email", "phone", "x", "a long column name with spaces"} { + if err := validateConfig(Config{Context: ctx}); err != nil { + t.Errorf("validateConfig(Context=%q) = %v, want nil", ctx, err) + } + } +} + +// TestNew_RejectsInvalidConfig is the public New path. Every +// validateConfig failure must surface as ErrInvalidConfig so callers +// can distinguish startup errors from runtime errors. +func TestNew_RejectsInvalidConfig(t *testing.T) { + _, err := New(newFakeProvider(t), Config{Context: ""}) + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("New(empty Context) = %v, want ErrInvalidConfig", err) + } +} + +// TestNew_AcceptsValidConfig: any value validateConfig accepts must +// be accepted by New too. +func TestNew_AcceptsValidConfig(t *testing.T) { + for _, ctx := range []string{"email", "phone", "x"} { + if _, err := New(newFakeProvider(t), Config{Context: ctx}); err != nil { + t.Errorf("New(Context=%q) = %v, want nil", ctx, err) + } + } +} + +// TestNew_DefaultConfigRejected pins the no-default rule: passing +// no Config at all routes through DefaultConfig, which returns the +// zero Config, which validateConfig rejects. A caller who forgets +// to set Context is told at startup, not silently given a shared +// keyspace. +func TestNew_DefaultConfigRejected(t *testing.T) { + _, err := New(newFakeProvider(t)) + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("New(no Config) = %v, want ErrInvalidConfig", err) + } +} + +// TestApplyDefaults_IsPassThrough pins the "Context is not defaulted" +// lesson from the brief: applyDefaults does NOT fill an empty +// Context with a placeholder, because any placeholder would make +// every field share one keyspace. validateConfig is the only thing +// that decides what Context values are allowed. +func TestApplyDefaults_IsPassThrough(t *testing.T) { + if got := applyDefaults(Config{}).Context; got != "" { + t.Errorf("applyDefaults(Config{}).Context = %q, want empty (zero must reach validateConfig)", got) + } + if got := applyDefaults(Config{Context: "email"}).Context; got != "email" { + t.Errorf("applyDefaults({email}).Context = %q, want email (explicit value must survive)", got) + } +} diff --git a/auth/field/field_fuzz_test.go b/auth/field/field_fuzz_test.go new file mode 100644 index 0000000..a636ec5 --- /dev/null +++ b/auth/field/field_fuzz_test.go @@ -0,0 +1,100 @@ +package field + +// Fuzz targets for the field module. Two are required: feed arbitrary +// strings to Decrypt and assert it never panics and never returns a +// nil error for a random input, and round trip arbitrary plaintexts +// through Encrypt and Decrypt and assert equality. Modeled on +// auth/credential/credential_fuzz_test.go. + +import ( + "encoding/base64" + "testing" +) + +// FuzzDecrypt drives Decrypt with arbitrary input. Decrypt accepts +// ciphertext from the database, so every input is potentially +// adversarial: it must never panic and must never return a nil error +// for a ciphertext the module did not produce. The only path to a +// nil result is a ciphertext that (a) base64-decodes, (b) is at +// least nonce+tag bytes long, and (c) authenticates against the +// AAD the module was built with. The fuzzer can construct (a) and +// (b) but not (c) without breaking AES-GCM. +func FuzzDecrypt(f *testing.F) { + mod, err := New(newFakeProvider(f), Config{Context: "email"}) + if err != nil { + f.Fatalf("field.New: %v", err) + } + + // Seed corpus: empty, a few shapes that should fail (too + // short, bad base64), and a tampered ciphertext that should + // still fail. We deliberately do NOT seed the happy-path + // ciphertext the module just produced, because the fuzz body + // treats any nil result as a failure and the only path to + // nil is a ciphertext the module minted. The fuzzer cannot + // reconstruct that without the AES key. + ct, err := mod.Encrypt("alice@example.com") + if err != nil { + f.Fatalf("Encrypt: %v", err) + } + raw, _ := base64.RawStdEncoding.DecodeString(ct) + tampered := make([]byte, len(raw)) + copy(tampered, raw) + tampered[len(tampered)-1] ^= 0xFF + + f.Add("") + f.Add("!") + f.Add("abc") + f.Add(base64.RawStdEncoding.EncodeToString(tampered)) + f.Add(base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3})) + f.Add("====not base64====") + f.Add("eA") // a real base64 string of 1 byte + + f.Fuzz(func(t *testing.T, ciphertext string) { + // Decrypt must never panic. Returning ErrDecrypt is the + // expected outcome for every seed; the only path to a + // non-error result is a ciphertext the module produced + // under the same context, which the fuzzer cannot + // reconstruct without the AES key. + pt, err := mod.Decrypt(ciphertext) + if err == nil { + t.Fatalf("Decrypt accepted adversarial input %q (got %q)", ciphertext, pt) + } + }) +} + +// FuzzEncryptDecrypt drives Encrypt and Decrypt in sequence with +// arbitrary input. The encrypt path draws a fresh nonce and seals; +// the decrypt path must hand back the exact bytes the caller +// produced. Every byte sequence must round trip because the AAD +// only depends on the bound Context, not the plaintext. +func FuzzEncryptDecrypt(f *testing.F) { + mod, err := New(newFakeProvider(f), Config{Context: "email"}) + if err != nil { + f.Fatalf("field.New: %v", err) + } + + // Seed corpus: empty, ASCII, multibyte UTF-8, embedded NUL, + // invalid UTF-8 byte sequences, and a long string to stress + // the seal buffers. + f.Add("") + f.Add("alice@example.com") + f.Add("a\x00b") + f.Add("\xff\xfe\xfd") + f.Add("\x00") + f.Add("éèêë 中文 🎉") + f.Add(string([]byte{0xC3, 0x28, 0xFE, 0xFF})) + + f.Fuzz(func(t *testing.T, plaintext string) { + ct, err := mod.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt(%q) returned error: %v", plaintext, err) + } + pt, err := mod.Decrypt(ct) + if err != nil { + t.Fatalf("Decrypt(Encrypt(%q)) returned error: %v", plaintext, err) + } + if pt != plaintext { + t.Fatalf("round trip mismatch: Encrypt(%q) -> Decrypt -> %q", plaintext, pt) + } + }) +} diff --git a/auth/field/field_test.go b/auth/field/field_test.go new file mode 100644 index 0000000..ed77ae1 --- /dev/null +++ b/auth/field/field_test.go @@ -0,0 +1,268 @@ +package field + +// Round-trip and basic-shape tests for the field module. The package-internal +// test scope (package field rather than field_test) lets the suite import +// unexported helpers like buildAAD when needed. Same pattern as auth/credential +// and auth/totp. + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/Glyndor/authcore" +) + +// ---- test doubles ----------------------------------------------------------- + +type fakeKeys struct{ secret []byte } + +func (fakeKeys) PrivateKey() ed25519.PrivateKey { return nil } +func (fakeKeys) PublicKey() ed25519.PublicKey { return nil } +func (k fakeKeys) RefreshSecret() []byte { return k.secret } +func (fakeKeys) KeyID() string { return "test" } + +type fakeProvider struct{ keys authcore.Keys } + +func (fakeProvider) Config() authcore.Config { return authcore.DefaultConfig() } +func (fakeProvider) Logger() authcore.Logger { return silentLogger{} } +func (p fakeProvider) Keys() authcore.Keys { return p.keys } + +type silentLogger struct{} + +func (silentLogger) Debug(string, ...any) {} +func (silentLogger) Info(string, ...any) {} +func (silentLogger) Warn(string, ...any) {} +func (silentLogger) Error(string, ...any) {} + +func newFakeProvider(tb testing.TB) fakeProvider { + tb.Helper() + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + tb.Fatalf("generate test HKAC secret: %v", err) + } + return fakeProvider{keys: fakeKeys{secret: secret}} +} + +// newFld builds a Field for tests. The provider is fresh per call so two +// modules share a key only when the caller explicitly hands them the +// same provider. +func newFld(tb testing.TB, context string) *Field { + tb.Helper() + mod, err := New(newFakeProvider(tb), Config{Context: context}) + if err != nil { + tb.Fatalf("field.New: %v", err) + } + return mod +} + +// ---- Name / module wiring --------------------------------------------------- + +func TestName(t *testing.T) { + if got := newFld(t, "email").Name(); got != "field" { + t.Errorf("Name() = %q, want field", got) + } +} + +func TestNew_SatisfiesModule(t *testing.T) { + var m authcore.Module = newFld(t, "email") + if m.Name() != "field" { + t.Errorf("module Name() = %q, want field", m.Name()) + } +} + +// ---- Round trip ------------------------------------------------------------- + +// TestRoundTrip_HappyPath is the basic Encrypt/Decrypt cycle on a normal +// string. Removing Decrypt or Encrypt makes the test fail. +func TestRoundTrip_HappyPath(t *testing.T) { + f := newFld(t, "email") + ct, err := f.Encrypt("alice@example.com") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + pt, err := f.Decrypt(ct) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if pt != "alice@example.com" { + t.Errorf("round trip = %q, want alice@example.com", pt) + } +} + +// TestRoundTrip_EmptyString covers the empty plaintext. Empty is a +// legitimate value (an optional field that the user did not fill) and +// must round trip like any other value. +func TestRoundTrip_EmptyString(t *testing.T) { + f := newFld(t, "email") + ct, err := f.Encrypt("") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + pt, err := f.Decrypt(ct) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if pt != "" { + t.Errorf("empty round trip = %q, want empty", pt) + } +} + +// TestRoundTrip_LongString covers a plaintext longer than the GCM +// internals step on. A long string is a stress test of the nonce, +// AAD, and seal buffers and must round trip cleanly. +func TestRoundTrip_LongString(t *testing.T) { + f := newFld(t, "email") + plain := strings.Repeat("a long plaintext ", 1000) // ~18 KB + ct, err := f.Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + pt, err := f.Decrypt(ct) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if pt != plain { + t.Error("long round trip mismatch") + } +} + +// TestRoundTrip_InvalidUTF8 covers a plaintext that is not valid UTF-8. +// Decrypt hands the bytes back to the caller as-is, so an arbitrary +// byte sequence must survive the round trip untouched. +func TestRoundTrip_InvalidUTF8(t *testing.T) { + f := newFld(t, "email") + plain := string([]byte{0x00, 0xC3, 0x28, 0xFE, 0xFF, 0x80, 0x81, 0x82, 0xA0, 0xA1}) + ct, err := f.Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + pt, err := f.Decrypt(ct) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if pt != plain { + t.Errorf("invalid-UTF8 round trip = %q, want %q", pt, plain) + } +} + +// ---- Nonce uniqueness ------------------------------------------------------- + +// TestEncrypt_DifferentCiphertextsForSamePlaintext is the nonce test. +// If Encrypt ever derives the nonce from the plaintext, the two +// ciphertexts will match; here they must not. Both must also still +// decrypt back to the same plaintext. +func TestEncrypt_DifferentCiphertextsForSamePlaintext(t *testing.T) { + f := newFld(t, "email") + const plain = "alice@example.com" + + ct1, err := f.Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt #1: %v", err) + } + ct2, err := f.Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt #2: %v", err) + } + if ct1 == ct2 { + t.Fatal("two Encrypt calls of the same plaintext returned the same ciphertext; nonce is being derived or reused") + } + + pt1, err := f.Decrypt(ct1) + if err != nil { + t.Fatalf("Decrypt #1: %v", err) + } + pt2, err := f.Decrypt(ct2) + if err != nil { + t.Fatalf("Decrypt #2: %v", err) + } + if pt1 != plain || pt2 != plain { + t.Errorf("round trip mismatch: %q / %q, want %q", pt1, pt2, plain) + } +} + +// ---- Decrypt failure modes -------------------------------------------------- + +// TestDecrypt_TamperedNonce flips one byte in the nonce region (the +// first 12 bytes of the decoded ciphertext) and asserts the result +// is ErrDecrypt, not a panic. This is the half of the bit-flip test +// that hits the nonce. +func TestDecrypt_TamperedNonce(t *testing.T) { + f := newFld(t, "email") + ct, err := f.Encrypt("alice@example.com") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + raw, err := base64.RawStdEncoding.DecodeString(ct) + if err != nil { + t.Fatalf("decode ciphertext: %v", err) + } + // Flip one bit in byte 0 (well inside the 12-byte nonce). + raw[0] ^= 0x01 + tampered := base64.RawStdEncoding.EncodeToString(raw) + + if _, err := f.Decrypt(tampered); !errors.Is(err, ErrDecrypt) { + t.Errorf("Decrypt with tampered nonce: got %v, want ErrDecrypt", err) + } +} + +// TestDecrypt_TamperedSealed flips one byte in the sealed region +// (past the 12-byte nonce) and asserts ErrDecrypt. AAD binding would +// also be caught here if the byte happened to be in the tag; this +// is the half of the bit-flip test that hits the sealed payload. +func TestDecrypt_TamperedSealed(t *testing.T) { + f := newFld(t, "email") + ct, err := f.Encrypt("alice@example.com") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + raw, err := base64.RawStdEncoding.DecodeString(ct) + if err != nil { + t.Fatalf("decode ciphertext: %v", err) + } + // Flip one byte past the nonce, well into the sealed payload. + raw[nonceLen+2] ^= 0xFF + tampered := base64.RawStdEncoding.EncodeToString(raw) + + if _, err := f.Decrypt(tampered); !errors.Is(err, ErrDecrypt) { + t.Errorf("Decrypt with tampered sealed: got %v, want ErrDecrypt", err) + } +} + +// TestDecrypt_TruncatedBelowNonce covers input shorter than the +// 12-byte nonce. The brief is explicit: this must return ErrDecrypt +// and must not panic or index out of range. The function checks +// length before slicing. +func TestDecrypt_TruncatedBelowNonce(t *testing.T) { + f := newFld(t, "email") + // 5 bytes, base64-encoded to a 7-character string. + short := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4, 5}) + if _, err := f.Decrypt(short); !errors.Is(err, ErrDecrypt) { + t.Errorf("Decrypt(short): got %v, want ErrDecrypt", err) + } +} + +// TestDecrypt_EmptyString covers the empty string. It is not valid +// base64 for any non-empty payload and must return ErrDecrypt. +func TestDecrypt_EmptyString(t *testing.T) { + f := newFld(t, "email") + if _, err := f.Decrypt(""); !errors.Is(err, ErrDecrypt) { + t.Errorf("Decrypt(\"\"): got %v, want ErrDecrypt", err) + } +} + +// TestDecrypt_InvalidBase64 covers input that is not valid base64. +// The brief requires ErrDecrypt, not a panic. The base64 decoder +// itself does not panic on bad input; this pins that. +func TestDecrypt_InvalidBase64(t *testing.T) { + f := newFld(t, "email") + // RawStdEncoding rejects '='; a long-enough string of invalid + // bytes is enough to trigger the decoder error path. + bad := "!!!!notbase64!!!!" + if _, err := f.Decrypt(bad); !errors.Is(err, ErrDecrypt) { + t.Errorf("Decrypt(invalid base64): got %v, want ErrDecrypt", err) + } +} diff --git a/auth/jwt/denylist.go b/auth/jwt/denylist.go index cf8112a..9be8aa5 100644 --- a/auth/jwt/denylist.go +++ b/auth/jwt/denylist.go @@ -25,6 +25,13 @@ const defaultDenylistTimeout = 5 * time.Second // SessionID and is stable across rotations — so revoking one entry kills the // whole session, every access token in it, immediately. // +// That holds while you refresh with RotateTokens, which carries the original +// jti forward. CreateTokens mints a fresh one on every call, so a deployment +// that refreshes by calling CreateTokens instead must store the newest +// SessionID and revoke that: access tokens issued under an earlier jti are not +// covered by the entry and remain valid until their own exp. See the "When not +// to rotate" section of docs/jwt.md. +// // Leaving Config.Denylist nil keeps the stateless fast path: no lookup, no // store, no per-request cost. type Denylist interface { diff --git a/docs/configuration.md b/docs/configuration.md index 5d5f361..fd88bf2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,8 @@ breaks? | `email` | `RejectPlusAddressing` | RFC 5321/5322 parse and normalisation · IDN punycode conversion | | `username` | `MinLength`, `MaxLength`, `ExtraReservedNames`, `AllowReservedNames` | Character set `[a-z0-9_-]` · lowercase + trim normalisation · "must start and end with a letter or digit" rule · "no consecutive specials" rule | | `totp` | Clock-skew window: `SkewSteps` (`*int`, 0 to 10, default 1, set with `totp.Int`) · `RecoveryCodeCount` (1 to 50, default 10) · `Issuer` (label shown in the authenticator) | HMAC-SHA1 algorithm · 30-second time step · 6-digit codes · 20-byte secrets · constant-time compare · full-window scan before any return | +| `credential` | `TTL` (token lifetime, positive to 24h, default 1h) | 256-bit CSPRNG token · base64-URL no-padding encoding · HMAC-SHA256 hash with the library pepper · `purpose \|\| 0x00 \|\| subject \|\| 0x00 \|\| token` binding · constant-time compare run before the expiry check so wall-clock time does not reveal whether a token existed | +| `field` | `Context` (column name the module is protecting, required, no zero-value default) | AES-256-GCM with 12-byte random nonce per call · HKDF-SHA256 derivation of encryption and index keys from the library refresh secret with distinct `authcore/field/aes-256-gcm/v1` and `authcore/field/blind-index/v1` info labels · length-prefixed `Context` bound into both the GCM AAD and the blind index input · HMAC-SHA256 hex blind index · base64-RawStdEncoding of `nonce \|\| sealed` | A field listed under "closed" cannot be configured: trying to do so would either be rejected at compile time or be a deliberate error in the code. diff --git a/docs/credential.md b/docs/credential.md new file mode 100644 index 0000000..1744b49 --- /dev/null +++ b/docs/credential.md @@ -0,0 +1,160 @@ +# Single-use credential tokens + +`auth/credential` mints the high-entropy, time-bounded, single-use +tokens that power password reset and account activation - the two most +security-sensitive emails an application ever sends. The module hands +back the raw token once (for the email link) and a keyed hash (for the +caller to store), then verifies a presented token against that stored +hash with the same purpose and subject it was minted for. + +The library never stores anything; you own the database. See the +[error reference](errors.md). + +## Setup + +```go +auth, err := authcore.New(authcore.DefaultConfig()) +cred, err := credential.New(auth) // defaults (TTL=1h) +cred, err := credential.New(auth, credential.Config{TTL: 15 * time.Minute}) +``` + +## Password reset flow + +```go +// 1. User clicks "I forgot my password". Issue a reset token. +issued, err := cred.Issue("reset", user.Email) +if err != nil { return http.StatusInternalServerError } + +// 2. Email the raw token in a link. NEVER store the raw token. +link := "https://app.example.com/reset?token=" + url.QueryEscape(issued.Token) +sendEmail(user.Email, "Reset your password", linkBody(link)) + +// 3. Persist the hash alongside the issuance timestamp. The hash binds +// purpose and subject into a single value - a "reset" token can +// never be redeemed against an "activate" flow, and alice's token +// can never be redeemed for bob. +db.StoreResetToken(user.ID, issued.Hash, time.Now()) + +// 4. User clicks the link. Verify and act, in the same transaction +// that consumes the token. +stored, err := db.FindResetToken(user.ID) +if err != nil { return genericError() } + +err = cred.Verify("reset", user.Email, + form.Token, stored.Hash, stored.IssuedAt) +switch { +case errors.Is(err, credential.ErrExpired), + errors.Is(err, credential.ErrInvalidCredential): + // Same generic message either way: distinguishing them tells an + // attacker that a token existed. + return http.StatusOK // "link invalid or expired" +case err != nil: + return http.StatusInternalServerError +} + +// Single-use: delete the row in the same transaction that updates +// the password. A second click on the same link must now fail. +db.DeleteResetToken(user.ID) +db.UpdatePassword(user.ID, newHash) +``` + +## Account activation flow + +The shape is identical; the only change is the `purpose` string. + +```go +issued, err := cred.Issue("activate", newUser.Email) +sendEmail(newUser.Email, "Activate your account", linkBody(activationLink(issued.Token))) +db.StoreActivationToken(newUser.ID, issued.Hash, time.Now()) + +// Later, when the user clicks the activation link: +err = cred.Verify("activate", newUser.Email, + form.Token, stored.Hash, stored.IssuedAt) +// ... same generic-error handling as the reset flow ... +db.MarkUserActive(newUser.ID) // and delete the activation token +``` + +A token minted for activation can never be redeemed against reset, +because the purpose is bound into the stored hash and Verify recomputes +it from the caller's arguments. + +## What you must do on top + +The module does three things well: it makes the token unguessable +(256-bit CSPRNG), it binds the token to its purpose and subject so it +cannot be redeemed against the wrong flow or the wrong user, and it +checks expiry in constant time against wall-clock drift. Three things +the module deliberately does NOT do, because they belong to the +application and forgetting any one of them ships a broken reset flow: + +1. **Single-use is the caller's job.** The module does not remember + anything; it cannot tell whether a token has been redeemed before. + Delete or flag the stored hash in the same transaction that applies + the effect (the `db.DeleteResetToken` line above). Without that + step, a stolen link can be spent over and over until it expires on + its own. + +2. **Changing the password must invalidate outstanding reset tokens + for that user.** An old reset link in an old inbox still works + after the user has recovered their account, which is exactly the + case reset exists to close. A `DELETE FROM reset_tokens WHERE + user_id = ?` in the password-change transaction closes it. + +3. **Rate limit issuance per subject.** Otherwise the endpoint is a + mail bomb aimed at any address the attacker names. Throttle per + email and per IP, and surface a generic "if that address exists, + we sent a link" message so the existence oracle does not leak. + +## Token shape + +- 32 random bytes (256 bits) from `crypto/rand` +- base64 URL **without padding**, so the token drops into a query + parameter as-is and `url.QueryEscape(token) == token` +- The hash is `HMAC-SHA256(pepper, purpose || subject || token)` + hex-encoded, with each field prefixed by its length as a big-endian + `uint32`. That is what makes `(purpose="reset", subject="ab")` + unable to collide with `(purpose="reseta", subject="b")`, and unlike + a separator byte it holds no matter what the fields contain: a Go + string can hold a NUL, so `("a\x00b", "c")` and `("a", "b\x00c")` + would hash alike under a `0x00` separator. The pepper is the + library's managed refresh secret, never the database row. +- Verify compares the recomputed hash against `storedHash` in constant + time, runs the comparison before the expiry check, and returns + `ErrInvalidCredential` or `ErrExpired` - the caller shows the same + generic message for both. + +## Footguns the caller must handle + +Beyond the three above, two smaller traps: + +- **Store `issuedAt` alongside the hash.** Without it the caller + cannot pass a meaningful timestamp to Verify, and the module falls + back to "every link is expired". A two-column row (hash, issued_at) + is enough. +- **Bind the subject to something stable and unique.** The brief's + example uses `user.Email`, which is the right choice if email is + the account identifier. If the caller uses a mutable field (a + display name, say), a user who renames themselves invalidates + their own outstanding reset links. Email is the conventional + choice; a frozen user ID or account number works too. + +## What is fixed and why + +The cryptographic layer is **closed**: token entropy, the +HMAC-SHA256 construction and its library-managed pepper, the +constant-time comparison, the base64-URL encoding, and the binding of +purpose and subject into the stored hash are not configurable. Weaken +any of these and a stolen email can be spent against the wrong user +or the wrong flow. + +The policy layer is **open with secure defaults**: the TTL (how long +a token remains redeemable) is configurable within a 1-nanosecond to +24-hour range, enforced by `validateConfig`. The 24-hour cap is +deliberately tight - a reset link that lives longer than a day is a +standing key to the account sitting in an inbox. + +`TTL` is a plain `time.Duration`, not a pointer, because zero is +refused rather than defaulted - `New(auth, credential.Config{})` +fails with `ErrInvalidConfig`. This is the contrast with `totp`'s +`SkewSteps`, where zero is a meaningful "no tolerance" value. See +[configuration](configuration.md) for the principle. diff --git a/docs/field.md b/docs/field.md new file mode 100644 index 0000000..c2837bd --- /dev/null +++ b/docs/field.md @@ -0,0 +1,224 @@ +# Field-level encryption with a blind index + +`auth/field` encrypts a single column's value and produces a separate +"blind index" that lets the database enforce uniqueness on the +encrypted value without ever seeing the plaintext. The case that +drives it: store a user's email address encrypted, and still enforce +one account per address. + +The module does three things: it makes the ciphertext unreadable +(AES-256-GCM with a fresh nonce per call), it makes a stolen +ciphertext from one column useless against another (the column name +is bound into the GCM additional authenticated data), and it gives +the caller a deterministic hash of the plaintext under the same +binding so a `UNIQUE` index on the index column enforces one +account per email. The library never stores anything; you own the +database. See the [error reference](errors.md). + +## Setup + +```go +auth, err := authcore.New(authcore.DefaultConfig()) +fld, err := field.New(auth, field.Config{Context: "email"}) +// One module per column. Context is the column name; it is bound +// into the AAD and the index so a ciphertext from "email" cannot +// be decrypted as "phone". +fldPhone, err := field.New(auth, field.Config{Context: "phone"}) +``` + +`Context` has no default. An empty value is rejected at `New` with +`field.ErrInvalidConfig`, because silently accepting `""` would +make every field share one keyspace and the whole point of the +binding would vanish. + +## Encrypted email with uniqueness, end to end + +```sql +-- The table shape. ciphertext stores the encrypted email; idx +-- stores the blind index. The UNIQUE index on idx is what gives +-- "one account per address" without the database ever seeing the +-- plaintext. +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email_ct TEXT NOT NULL, -- field.Encrypt(plaintext) + email_idx TEXT NOT NULL -- field.BlindIndex(plaintext) +); +CREATE UNIQUE INDEX users_email_idx_uniq ON users (email_idx); +``` + +```go +// 1. Normalise the input. The module never normalises; index the +// same form you store, every time, or lookups miss. auth/email +// and auth/username both do this. +plain := strings.ToLower(strings.TrimSpace(userInput)) + +// 2. Encrypt the plaintext for storage. The output drops into a +// TEXT column as is; a BYTEA-style column can store the raw +// bytes via base64.RawStdEncoding.DecodeString. +ct, err := fldEmail.Encrypt(plain) +if err != nil { return serverError() } + +// 3. Produce the blind index the UNIQUE constraint runs against. +idx := fldEmail.BlindIndex(plain) + +// 4. Insert. ON CONFLICT (email_idx) DO NOTHING enforces uniqueness +// at the database; the application learns whether the row was +// taken via RowsAffected. +res, err := db.Exec(` + INSERT INTO users (email_ct, email_idx) VALUES ($1, $2) + ON CONFLICT (email_idx) DO NOTHING`, + ct, idx) +if err != nil { return serverError() } +n, _ := res.RowsAffected() +if n == 0 { return conflictError() } // generic: "could not create account" + +// 5. Login path: hash the candidate, look up the row, then decrypt. +// A hit in the blind index proves the ciphertext came from a row +// that shared the same plaintext; a miss proves it didn't. +candidate := fldEmail.BlindIndex(strings.ToLower(strings.TrimSpace(form.Email))) +row := db.QueryRow(`SELECT email_ct FROM users WHERE email_idx = $1`, candidate) +var stored string +if err := row.Scan(&stored); err != nil { return notFound() } +plain, err := fldEmail.Decrypt(stored) +if err != nil { return serverError() } +``` + +The `ON CONFLICT (email_idx) DO NOTHING` pattern is the whole +point: the database sees only the index, which it can compare for +equality, and the ciphertext, which it cannot. The application +never asks "is this email already in use?" with a plaintext query, +which would defeat the encryption. + +## What you must do on top + +The module does three things well: it makes the ciphertext +unreadable (AES-256-GCM with a fresh 12-byte nonce per call), it +makes a stolen ciphertext from one column useless against another +(the column name is bound into the GCM AAD), and it gives the +caller a deterministic hash so a `UNIQUE` index on the index +column enforces uniqueness. Four things the module deliberately +does NOT do, because they belong to the application and +forgetting any one of them ships a broken field: + +1. **Normalisation is the caller's.** `BlindIndex` hashes the + bytes it is handed. Lowercase, trim, Unicode fold, strip + `+tags` from addresses, whatever the application's + "same address" rule is, do it once, in one place, and + `BlindIndex` the same form every time. `auth/email` and + `auth/username` both do this. A module that normalised + silently would make `BlindIndex` disagree with whatever + the caller stored, and lookups would miss. The cost of + getting this wrong is not "the user sees an error"; it is + "the user creates a second account under a different + capitalisation", which is the exact failure the column was meant + to prevent. + +2. **The blind index leaks equality, on purpose.** Anyone with + read access to the database can see which rows share an + index, and can confirm a guess if they can compute the + index, which needs the derived key. The index buys + searchability and costs exactly that. Do NOT index a + low-entropy field where confirming a guess is the whole + attack: a four-digit SMS code, a yes/no flag, a country + code. For an email address, the search space is large + enough that equality is the right tradeoff. For a PIN, it + is not. + +3. **Losing the key loses the data.** There is no recovery + path. The library-managed refresh secret (the input to the + HKDF derivation) is the only thing that can produce the + AES key; lose it, and every row is unreadable. Back up the + key material the same way you back up the database. See + [key management](key-management.md) for sourcing it from + a secret manager. + +4. **Rotating the key requires re-encrypting every row.** + The library does not do it for you, because doing so + without a window where the row is decryptable by either + the old key or the new one would either require keeping + both around or making the migration the caller's problem + in a different shape. The shape is: read with the old + module, write with the new one, in batches, in a single + transaction per row so a partial run leaves no row in + a state neither key can read. + + ```go + // Migration sketch. Run in batches of 1000 rows; every + // row is in a transaction so a crash mid-batch leaves + // the rest of the table consistent. + for { + rows, _ := db.Query(`SELECT id, email_ct FROM users + WHERE email_migrated_at IS NULL + LIMIT 1000`) + if !rows.Next() { break } + // ... open tx, read with old module, write with new, + // set email_migrated_at, commit ... + } + ``` + +## Ciphertext shape + +- 12 random bytes (96 bits) of nonce from `crypto/rand`, drawn + fresh on every `Encrypt` +- AES-256-GCM over the plaintext, with the bound `Context` + (length-prefixed with a big-endian uint32) as the additional + authenticated data +- Output: `base64.RawStdEncoding.EncodeToString(nonce || sealed)`, + so it drops into a TEXT column as-is. A `BYTEA`-style column + can store the raw bytes via + `base64.RawStdEncoding.DecodeString(ciphertext)` instead. +- The blind index is `hex(HMAC-SHA256(idxKey, len(context)||context + || len(value)||value))`, each length a big-endian uint32, so + `("a", "bc")` and `("ab", "c")` cannot collide. A separator + byte would only disambiguate while no field contained it, and + a Go string can contain any byte. + +The encryption key and the index key are both derived from +`Keys().RefreshSecret()` with HKDF-SHA256 and distinct info +labels (`authcore/field/aes-256-gcm/v1` and +`authcore/field/blind-index/v1`). The version suffix in the +info string is deliberate: if the derivation ever has to +change, the old label stays available so existing rows remain +decryptable. + +## Footguns the caller must handle + +Beyond the four above, two smaller traps: + +- **Use one module per column.** The same `fld` instance + protects one column, named in its `Context`. Two columns + need two `field.New` calls with two different `Context` + strings. A single module shared across columns would not + bind to a column, and the whole point of the AAD would + vanish. +- **Treat `ErrDecrypt` as a server error, not a 404.** A + stored ciphertext that does not decrypt is corrupt, was + never written by this module, or was written under a + different `Context`. None of those is "the user does + not exist", and surfacing it as one would let an + attacker probe the database by writing rows they know + will fail to decrypt. Return a generic 500 and log + the offending row id. + +## What is fixed and why + +The cryptographic layer is **closed**: AES-256-GCM, a +12-byte random nonce per `Encrypt` from `crypto/rand`, the +HKDF-SHA256 derivation of the encryption and index keys +from the library-managed refresh secret, the HMAC-SHA256 +construction of the blind index, the length-prefixed +binding of the `Context` into both the AES additional +authenticated data and the index, and the base64 +encoding of the ciphertext are all fixed. Weaken any of +these and a stolen ciphertext, or a guessed value, can +recover data the column is meant to protect. + +The policy layer is **open with one required field**: +`Context`, the name of the database column the module +is protecting. `Context` is not decoration: it is the +whole point of the AAD binding. A caller who does not +name the field is telling the module nothing, and +silently accepting `""` would make every field share +one keyspace, so `validateConfig` refuses the empty +string at `New`. See [configuration](configuration.md) +for the principle. diff --git a/docs/jwt.md b/docs/jwt.md index 34aeffd..c2e670f 100644 --- a/docs/jwt.md +++ b/docs/jwt.md @@ -112,6 +112,77 @@ db.ReplaceRefreshHash(session.ID, newPair.RefreshTokenHash) // 6. Send the new tokens to the client. ``` +### When not to rotate + +Rotation is the recommended default, but there is one shape of client where it +actively hurts: a frontend that fires several requests in parallel. Two of them +find the access token expired at the same moment, both refresh with the same +refresh token, and the second arrives after step 5 has already replaced the +hash. It gets a 401 for a token that was valid when it was sent, and the user is +signed out mid-session for no reason. Next.js applications hit this often, +because the framework fans out data fetches on a single navigation. + +Staying non-rotating is supported. Issue with `CreateTokens`, store the hash, +and verify with `VerifyRefreshTokenHash` on each refresh without ever calling +`RotateTokens`: + +```go +// Refresh, without rotating: the client keeps the same refresh token. +if !jwtMod.VerifyRefreshTokenHash(clientToken, session.RefreshTokenHash) { + return http.StatusUnauthorized +} +// Mint a fresh pair, hand back only the access token, and keep the stored +// refresh hash as it is. Two requests racing here both succeed. +pair, err := jwtMod.CreateTokens(session.UserID, freshClaims) +if err != nil { + return http.StatusUnauthorized +} +db.UpdateSessionID(session.ID, pair.SessionID) // see below: this is required +// Return pair.AccessToken. Do not send pair.RefreshToken. +``` + +Two things you give up, and the second one surprises people. + +**You lose reuse detection.** Rotation is not what detects a stolen refresh +token; `RotateTokens` only verifies the presented token and reissues it. The +detection comes from step 5 above: once you have atomically replaced the stored +hash, a thief replaying the old token fails the lookup, and a legitimate client +failing the lookup tells you the token leaked. Drop rotation and you drop that +signal. + +**The denylist stops killing the whole session at once.** `CreateTokens` mints a +fresh `jti` on every call, while `RotateTokens` carries the original one +forward. Measured on a fixed clock: + +| Call | SessionID | +|---|---| +| `CreateTokens` | `018fd3ab-c200-771c-b0e0-17fa236d71af` | +| `CreateTokens` again | `018fd3ab-c200-7115-a937-fd114f66224d` | +| `RotateTokens` on the first pair | `018fd3ab-c200-771c-b0e0-17fa236d71af` | + +So under rotation every access token in a session shares one `jti`, and a single +denylist entry kills all of them instantly, which is what the `Denylist` +documentation promises. Refresh with `CreateTokens` instead and each refresh +starts a new `jti`, so you must store the newest `SessionID` on the session row +and revoke that one. Access tokens minted under an earlier `jti` are not covered +by that entry and stay valid until their own `exp`, which is `AccessTokenTTL`, +15 minutes by default. + +That is usually acceptable, but it is a different promise: instant for the +current segment, up to one access TTL for anything issued before it. If you need +the stronger guarantee, either rotate, or coalesce refreshes in the client so +only one is ever in flight. + +So the trade is real in both directions: + +- **Rotate** when your client refreshes from one place at a time, which covers + most mobile apps and server-rendered sessions. You get reuse detection and a + session-wide kill switch. +- **Do not rotate** when your client can refresh concurrently and you would + rather not build request coalescing. Compensate with a shorter + `AccessTokenTTL`, so the uncovered window shrinks, and keep the stored + `SessionID` current. + ## Revocation & logout Access tokens are **stateless JWTs**: once issued, an access token stays valid