From 71b161de9c5f7191e6b39598ddf089a6277336d4 Mon Sep 17 00:00:00 2001
From: Jose <75870284+Jaro-c@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:14:33 -0500
Subject: [PATCH 1/3] docs(jwt): say when not to rotate, and what that costs
(#337)
Closes item 6 of #325 and the first of its two friction points.
## Item 6 was mostly already done
`Denylist` is documented at length in `docs/jwt.md` (setup, store
example, `ErrTokenRevoked`, fail-closed, sizing entries to `exp`) and
again in section 6 of `docs/secure-login.md`. The issue says it "appears
nowhere in `docs/secure-login.md`", and that is no longer true.
The one place it really was missing is the README module table, where
the jwt row listed rotation and stopped. That row now names the
denylist. That is the whole of item 6, and I would rather say so than
manufacture work around a claim that has aged out.
The other friction point needed nothing either: `docs/key-management.md`
already covers environment-provided key material under "Sourcing keys
without a volume", with `os.Getenv` in the example.
## The rotation caveat was genuinely undocumented
Nothing in `docs/` mentioned the parallel-request race, and it is real:
a frontend that fans out several fetches on one navigation can have two
of them refresh with the same token, and the second arrives after the
stored hash has been replaced. It gets a 401 for a token that was valid
when it was sent. `CreateTokens`, `HashRefreshToken` and
`VerifyRefreshTokenHash` already support staying non-rotating; now the
docs say so, with the code.
They also say what it costs, because "rotation is optional" without that
is worse than silence.
**Reuse detection**, and where it actually lives. `RotateTokens` detects
nothing: it verifies the presented token and reissues. The detection is
the caller atomically replacing the stored hash, so a replayed old token
fails the lookup. Drop rotation and you drop that signal.
**The denylist stops killing the whole session at once.** This one I did
not expect, and it is written down nowhere. `RotateTokens` carries the
original `jti` forward; `CreateTokens` mints a fresh one per call.
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` |
`denylist.go` promises that one entry kills the whole session "because
the jti is stable across rotations". True, and it holds only while you
rotate. Refresh by calling `CreateTokens` and each refresh starts a new
`jti`, so the caller must store the newest `SessionID` and revoke that
one, while access tokens minted under an earlier `jti` stay valid until
their own `exp` (one `AccessTokenTTL`, 15 minutes by default).
That is a different guarantee, not a broken one. But a reader told "just
add a denylist" would never find it, so `denylist.go` now carries the
caveat next to the promise, and `docs/jwt.md` explains the trade in both
directions.
## Verification
The session-id table above is measured, not read off the source: I drove
`CreateTokens` twice and `RotateTokens` once against a fixed clock and
printed the ids. My first draft of this section said to "compensate with
a Denylist" and would have shipped advice that quietly does not work,
which is why it is here as a table rather than a claim.
`go build`, `go vet`, `gofmt -l` clean, `go test -race ./...` 10 of 10
packages. The only code change is a doc comment.
## Still open in #325
`auth/field` and `auth/credential`. The latter is in progress on a
separate branch.
Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
---
README.md | 2 +-
auth/jwt/denylist.go | 7 +++++
docs/jwt.md | 71 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 79 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 15eb862..be65e55 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ 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. |
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/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
From dda75c300b8768560fbb106b7f7e987468b32fd8 Mon Sep 17 00:00:00 2001
From: Jose <75870284+Jaro-c@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:33:20 -0500
Subject: [PATCH 2/3] feat(credential): add auth/credential, single-use tokens
for reset and activation (#338)
Closes item 3 of #325.
Mints a high-entropy token, hands back the raw value once for the email
link and a hash for the caller to store, and verifies a presented token
in constant time with the TTL enforced. Pure computation, so it keeps
the no-database contract.
This is what powers password reset and account activation, the two most
security sensitive emails an application sends. `docs/secure-login.md`
walked the login path and stopped before both.
| Closed | Open |
|---|---|
| 32 bytes of CSPRNG entropy, base64url so it drops into a link | `TTL`,
default 1 hour, capped at 24 |
| HMAC-SHA256 with the library's pepper, length-prefixed input | |
| Constant-time comparison, run before the expiry check | |
| The purpose and subject binding | |
The TTL cap is not arbitrary: a reset link that outlives a day is a
standing key to the account sitting in an inbox.
## The binding is the point
A token minted to reset a password must not be redeemable to activate an
account, and one minted for user A must not verify for user B. Both are
mistakes a caller makes with one shared table or one lookup by token,
and neither should be reachable. Purpose and subject go into the HMAC;
`Verify` recomputes with the pair it was given.
## Two defects I found and fixed
**The separator was defeatable.** The first draft hashed `purpose ||
0x00 || subject || 0x00 || token`, with a comment claiming the zero byte
made the encoding unambiguous. It does, but only while no field can
contain that byte, and a Go string can. Measured:
```
purpose="a\x00b" subject="c" -> 65350024d7b06dec...
purpose="a" subject="b\x00c" -> 65350024d7b06dec...
```
Identical. A caller whose subject came from untrusted input could redeem
a token minted for a different pair, which is the exact property this
module exists to provide. Each field is now length-prefixed with a
big-endian `uint32`, which depends on nothing about the contents.
**The module carried per-issue state.** `Issue` also wrote the token and
hash onto the receiver. The race detector flags it on two concurrent
calls, and it kept the raw token, the one value shown exactly once,
alive for the lifetime of the module. That came from an ambiguous
instruction of mine, and a test had pinned it as if it were a feature.
The fields are gone; `Issued` is the only place the token appears.
Both are regression tests now, and both were checked by sabotage:
| Sabotage | Reddens |
|---|---|
| restore the `0x00` separator | the collision test |
| restore the receiver fields | the concurrency test, under `-race` |
## Corrected alongside
Four places still described the old separator or the receiver side
effect: the `Config` doc comment, the `Credential` struct comment, the
`Issue` doc comment, and `docs/credential.md`. A comment that outlives
its code is a false statement, so all four now say what the code does.
## Three footguns stated in `docs/credential.md`
1. **Single use is the caller's job.** Delete or flag the stored hash
when the token is redeemed, in the same transaction that applies the
effect.
2. **Changing a password must invalidate outstanding reset tokens for
that user.** Otherwise an old link in an old inbox still works after the
account has already been recovered, which is the case reset exists to
close.
3. **Rate limit issuance per subject**, or the endpoint is a mail bomb
aimed at any address an attacker names.
`ErrInvalidCredential` and `ErrExpired` are both safe to show, and the
docs say to show the same generic message for both: distinguishing them
tells an attacker that a token existed.
## Verification
`go build`, `go vet`, `gofmt -l` clean, `go test -race ./...` 11 of 11
packages, coverage 97.8 for this package against a floor of 90. Fuzz
target clean for 20 seconds, 3.9M executions.
## Still open in #325
`auth/field` only, the AES-256-GCM plus blind index one.
Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
---
README.md | 7 +-
auth/credential/config.go | 84 +++++++
auth/credential/credential.go | 257 ++++++++++++++++++++
auth/credential/credential_bind_test.go | 274 ++++++++++++++++++++++
auth/credential/credential_config_test.go | 90 +++++++
auth/credential/credential_fuzz_test.go | 61 +++++
auth/credential/credential_test.go | 163 +++++++++++++
auth/credential/credential_ttl_test.go | 158 +++++++++++++
auth/credential/errors.go | 53 +++++
docs/configuration.md | 1 +
docs/credential.md | 160 +++++++++++++
11 files changed, 1305 insertions(+), 3 deletions(-)
create mode 100644 auth/credential/config.go
create mode 100644 auth/credential/credential.go
create mode 100644 auth/credential/credential_bind_test.go
create mode 100644 auth/credential/credential_config_test.go
create mode 100644 auth/credential/credential_fuzz_test.go
create mode 100644 auth/credential/credential_test.go
create mode 100644 auth/credential/credential_ttl_test.go
create mode 100644 auth/credential/errors.go
create mode 100644 docs/credential.md
diff --git a/README.md b/README.md
index be65e55..becff69 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) can be used independently.
## Modules
@@ -79,13 +79,14 @@ Pick only what you need — each is independent, testable, and safe by default.
| 👤 | **[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. |
| 🌐 | **[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"]
M -->|hash · sign · verify| App
```
@@ -94,7 +95,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) · [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/docs/configuration.md b/docs/configuration.md
index 5d5f361..5af2058 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -21,6 +21,7 @@ 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 |
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.
From 639653664b06f63b2aa5cd0fe83cd1cd0cee7278 Mon Sep 17 00:00:00 2001
From: Jose <75870284+Jaro-c@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:53:54 -0500
Subject: [PATCH 3/3] feat(field): add auth/field, column encryption with a
blind index (#339)
Closes item 2 of #325. **That was the last one open, so #325 closes with
this.**
AES-256-GCM for a single database column, plus an HMAC-SHA256 blind
index so the value stays searchable by exact equality without being
readable. The case that drives it: store a user's email encrypted and
still enforce one account per address.
| Closed | Open |
|---|---|
| AES-256-GCM, 12 byte random nonce per call | `Context`, and it is
required |
| HMAC-SHA256 for the blind index | |
| HKDF derivation of both keys | |
| Length-prefixed AAD and index input | |
## Keys are derived, never reused
`Keys().RefreshSecret()` is already the pepper for refresh hashes, api
keys, totp recovery codes and credential tokens. Using it directly as an
AES key too would put one secret in five jobs, where a weakness in any
construction reaches the others.
Both keys come from `crypto/hkdf` (standard library on the pinned
toolchain, confirmed before writing the design) under versioned labels,
so a future change to the derivation can leave the old label available
to decrypt existing rows. Measured on one provider:
```
master secret eb9a04e93baae0007654afee...
encKey derived 5d334988a119c29c63094ff2...
idxKey derived 7015d342c627348db08ce789...
```
## Context binds a value to its column
`Config.Context` names the field being protected and goes into both the
GCM additional authenticated data and the blind index, length-prefixed.
A ciphertext lifted from the `email` column will not decrypt as `phone`,
and an index for one field never matches another. An empty `Context` is
refused: silently accepting `""` would put every field in one keyspace.
Length prefixing rather than a separator byte, for the reason
`auth/credential` documents: a separator disambiguates only while no
field can contain it, and a Go string can contain any byte.
## The test that no behaviour can see
Nothing in a round trip notices whether the keys were derived. Remove
`deriveKey`, use the master secret for both the AEAD and the index, and
**every existing test still passes**: encryption still round trips,
indexes still match. That is the shape of #229, where seventeen controls
could be deleted with the suite staying green.
So `TestDeriveKey_SeparatesTheTwoKeysFromEachOtherAndFromTheMaster`
asserts the keys directly: both 32 bytes, neither equal to the master,
not equal to each other, and deterministic across calls, because a
non-deterministic derivation would lose every stored row on restart.
Sabotaged by making `deriveKey` return the master secret, and it is the
only test that goes red.
## Sabotages
| Sabotage | Reddens |
|---|---|
| AAD dropped | both cross-context tests |
| nonce derived from the plaintext | the ciphertext-uniqueness test |
| length prefix removed from the index | both collision tests |
| `deriveKey` returns the master secret | the separation test, and only
that one |
Three earlier attempts were **inert** because my patterns did not match
the code, and a first go at the nonce one broke the build on an unused
import. Diffing the file before reading the result caught all four. An
inert sabotage reads exactly like a test that works, and a sabotage that
does not compile proves nothing.
## Wire format
`base64(nonce || sealed)`. Measured 28 bytes of overhead on a 17 byte
plaintext, which is 12 of nonce plus 16 of GCM tag.
## Four things `docs/field.md` states outright
1. **The blind index leaks equality, on purpose.** Anyone with the
database sees which rows share a value. Do not index a low-entropy field
where confirming a guess is the whole attack.
2. **Normalisation is the caller's.** The module does not lowercase or
trim: `auth/email` and `auth/username` already do, and a module that
normalised silently would disagree with whatever the caller stored.
3. **Losing the key loses the data.** No recovery path.
4. **Rotating the key means re-encrypting every row**, and the library
does not do it for you. The migration shape is written out.
## Verification
`go build`, `go vet`, `gofmt -l` clean, `go test -race ./...` 12 of 12
packages, coverage 90.5 for this package against a floor of 90. Both
fuzz targets clean.
Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
---
README.md | 7 +-
auth/field/config.go | 93 ++++++++++
auth/field/errors.go | 30 ++++
auth/field/field.go | 302 ++++++++++++++++++++++++++++++++
auth/field/field_bind_test.go | 266 ++++++++++++++++++++++++++++
auth/field/field_config_test.go | 76 ++++++++
auth/field/field_fuzz_test.go | 100 +++++++++++
auth/field/field_test.go | 268 ++++++++++++++++++++++++++++
docs/configuration.md | 1 +
docs/field.md | 224 +++++++++++++++++++++++
10 files changed, 1364 insertions(+), 3 deletions(-)
create mode 100644 auth/field/config.go
create mode 100644 auth/field/errors.go
create mode 100644 auth/field/field.go
create mode 100644 auth/field/field_bind_test.go
create mode 100644 auth/field/field_config_test.go
create mode 100644 auth/field/field_fuzz_test.go
create mode 100644 auth/field/field_test.go
create mode 100644 docs/field.md
diff --git a/README.md b/README.md
index becff69..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, credential) can be used independently.
+username, totp, credential, field) can be used independently.
## Modules
@@ -80,13 +80,14 @@ Pick only what you need — each is independent, testable, and safe by default.
| 🗝️ | **[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 · credential"]
+ Core -->|Provider| M["password · jwt · apikey · oauth · email
username · totp · credential · field"]
M -->|hash · sign · verify| App
```
@@ -95,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) · [Credential tokens](docs/credential.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/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/docs/configuration.md b/docs/configuration.md
index 5af2058..fd88bf2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -22,6 +22,7 @@ breaks?
| `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/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.