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