Refactor: layered architecture + pluggable hashing & token format - #29
Refactor: layered architecture + pluggable hashing & token format#29ssddOnTop wants to merge 17 commits into
Conversation
Proof of concept for the redesigned config layer: - New self-contained config_v2 module with ConfigBuilder -> ValidatedConfig - All field validation deferred to a single build() call - Uses tailcall-valid's Valid/fuse to accumulate EVERY config error at once instead of failing on the first, both across fields and within the prefix rules - ConfigErrors aggregates the flat list with a readable Display impl - 7 tests covering multi-field and multi-rule accumulation; full suite green Does not touch shipping code (ApiKeyManagerV0 untouched).
Big-bang restructure into a strict, one-directional dependency tree so the
generate and verify paths never import each other (breaking change; pre-1.0).
Layers (each only depends downward):
manager -> generate, verify
generate -> shared, config
verify -> shared, config
shared -> config
config (primitives + builder + errors)
Key changes:
- config.rs: single ConfigBuilder -> ValidatedConfig. All validation happens
once in build(), accumulating EVERY error via tailcall-valid (ConfigErrors)
instead of failing on the first. Replaces KeyConfig/HashConfig + scattered
with_* validation. Fixes the balanced() preset that bypassed its own
checksum-length check (default now 32, internally consistent).
- shared/checksum.rs: checksum logic EXTRACTED from the generator (removes the
old verify->generator cross-dependency and the FIXME(ARCHITECTURE)). Pure
compute() for the generate path, constant-time verify_ct() for verify.
- shared/{hasher,secure,token_parser}: moved into the shared layer; hasher now
takes HashSpec and returns a shared HashError; token_parser test no longer
reaches up into the manager.
- generate.rs: Generator + ApiKey typestate + GenerateError.
- verify.rs: Verifier + KeyStatus + VerifyError (separate error type; a wrong
key is Ok(Invalid), only structural failures are errors).
- manager.rs: slim ApiKeyManager orchestrator + InitError; builds the timing
dummy key/hash once and hands it to the verifier.
- Removed god-object ApiKeyManagerV0 and the single shared Error enum.
All unit + integration tests migrated to the new API; full suite green,
clippy clean. READMEs updated.
…mics
Adds docs/DESIGN-hash-algorithms-and-format.md: an unambiguous, step-by-step
plan (grounded in NIST 800-63B, OWASP, RFC 2104, GitHub token-format research).
Part A - Pluggable hash algorithms:
- HashAlgo enum: Sha256 (fast default), HmacSha256{pepper} (recommended,
keyed), Argon2id(Argon2Params) (opt-in). Rename HashSpec -> Argon2Params.
- Self-describing stored-hash format with algo-tag prefix so verify dispatches
on the stored string (enables mixed-algo DBs / rotation with no flag day).
- Exact API deltas, rename map, new deps, constant-time requirements.
Part B - Token-format ergonomics:
- '_' separator (GitHub-style), custom/optional Environment, type-safe units
(EntropyBytes/ChecksumBits/Mebibytes), example_key() preview, optional
keyed checksum (stretch).
Includes atomic A0-A5 / B1-B5 commit steps, a full test matrix as
definition-of-done, backwards-compat notes, and open decision points to resolve
before implementation.
- config: HashAlgo enum {Sha256, HmacSha256{pepper}, Argon2id(Argon2Params)}
with pepper-redacting Debug and a stable tag(). ValidatedConfig.hash is now
HashAlgo (getter returns &HashAlgo). Builder gains .hash(HashAlgo), drops
.hash_params(). validate_hash is algo-aware; new ConfigError::EmptyPepper.
- shared/hasher: KeyHasher dispatches on HashAlgo. Self-describing stored-hash
format: sha256$<hex>, hmac-sha256$<hex>, native $argon2id$ PHC. Adds
crate-internal verify()/dummy_verify() that dispatch on the STORED prefix
(enables mixed-algo DBs). Constant-time compare for digest arms.
- Default remains Argon2id(balanced) for now (flip to Sha256 in A5).
- Tests: per-algo hash/verify, deterministic sha/hmac, keyed hmac rejects wrong
pepper, prefix-dispatch semantics, key_id identical across algos, EmptyPepper,
Debug redaction. Lib suite green (75).
Note: verify.rs still uses the direct Argon2 path (rewired in A4); the new
KeyHasher::verify/dummy_verify are intentionally unused until then.
…on2 in verify.rs - Verifier now holds a KeyHasher + a String dummy_hash; verify_hash_and_expiry calls hasher.verify_key(bytes, stored_hash) and dummy_load calls hasher.dummy_verify. Removes argon2/password_hash imports from verify.rs (crypto now lives only in shared/hasher). - KeyHasher::verify_key(&[u8], &str) is the core; verify(&SecureString) wraps it. - Removed unused HashAlgo::tag(). - New integration test tests/hash_algorithms.rs: generate->verify Valid and wrong-key Invalid across all 3 algos, per-algo stored-hash tag, sha256 determinism, mixed-algo DB verifies by stored prefix (migration), and the checksum pre-filter working with a fast hash. Full suite green, clippy clean.
- ConfigBuilder::new() now defaults to HashAlgo::Sha256 (fast, NIST-appropriate for high-entropy keys). high_security() still uses Argon2id. - Pinned the timing/PHC-dependent tests to Argon2id explicitly (they assert the slow-hash behavior or the Argon2 PHC format): checksum_dos_protection's proceeds-to-argon2 + dos-comparison, security's argon2_phc_format, and manager::full_lifecycle now checks the sha256$ tag. - Updated docs/DESIGN: resolved decision points (Sha256 default, hex crate), added Part A completion status. - READMEs: pluggable-hashing feature bullets + a 'Choosing a hash algorithm' section (Sha256/HmacSha256/Argon2id) with the self-describing-format note. Part A complete. Full suite green, clippy clean. (Suite is also much faster now that the default no longer runs Argon2 on every generate/verify.)
…fying Replace String-payload error variants with transparent #[from] wrappers so the underlying error is preserved (and available via source()) rather than flattened to a message. - GenerateError: Rng(String)/Encoding(String) -> Rng(#[from] getrandom::Error), Encoding(#[from] base64::EncodeSliceError), Utf8(#[from] FromUtf8Error); Hashing stays #[from] HashError. All #[error(transparent)]. - HashError: was a String newtype catch-all; now an enum with Rng/PasswordHash/Argon2/HmacKey #[from] variants (transparent) plus a MissingSalt unit variant for the one synthetic case. - All map_err(|e| ...::new(format!(...))) call sites simplified to . - Enable argon2 'std' feature so argon2::Error and password_hash::Error impl std::error::Error (required for #[from]). Full suite green, clippy clean.
'_' is the dominant industry separator convention. Safe here because the token parser never re-splits the key body on the separator (only on '.'), so an underscore cannot create ambiguity. Adds strum serialize + round-trip and generate/verify tests (sk_live_<data>.<checksum>).
- Environment gains a Custom(String) variant (e.g. 'prod', 'sandbox'); dropped the strum derives in favor of a hand-written as_str()/Display since a data-carrying variant can't derive IntoStaticStr/EnumIter. Environment::custom() lowercases the label. variants() now returns just the four built-ins (used by the reserved-prefix-substring check). - ConfigBuilder::no_environment() + ValidatedConfig::include_environment(): when set, the env segment is omitted from generated keys (prefix<sep>[v<n><sep>]data), matching providers that fold role/env into the prefix (Stripe sk_live_...). generate()/generate_with_expiry() signatures are unchanged; the env arg is simply ignored in no-environment mode. - generate: env handled as Option<&str>; custom labels validated (1-20 [a-z0-9], not version-like) via new GenerateError::InvalidEnvironment so a label can't inject a separator/dot/version component. - Tests: custom-env emit + reject-invalid, no-env (with/without version), and manager-level verify round-trips for custom-env and no-env. Full suite green, clippy clean.
Fixes the long-standing hex-chars-vs-bits footgun. The builder's checksum() now takes ChecksumBits instead of a bare usize that was actually hex chars. - ChecksumBits::new(bits) with bits()/hex_len() (1 hex char = 4 bits). - checksum(algo, ChecksumBits): validation now speaks bits at the boundary (BLAKE3: 128-256 bits) and requires a multiple of 4 (new ConfigError::ChecksumBitsNotMultipleOf4). ChecksumSpec still stores hex chars internally (bits/4), so generator/verifier are unchanged. - Defaults preserved: balanced 128 bits (=32 hex), high_security 256 bits (=64). - Corrected the error messages that previously mislabeled the unit. - Tests: bit->hex conversion, in-range custom value, and each rejection (too small / too large / not multiple of 4). Deferred (recorded in the design doc): EntropyBytes/Mebibytes newtypes were judged low-value ceremony and skipped. Full suite green, clippy clean.
- Factored the token-format assembly out of raw_key into a shared assemble() helper, so previews cannot drift from real keys. - Generator::example_key(env) + ApiKeyManager::example_key(env): builds the token from a fixed all-zero entropy block (base64 'AAAA...'), never touches the CSPRNG, never hashes. Clearly documented as non-secret / not verifiable. - Lets devs preview 'sk_live_AAAA...AAAA.<checksum>' before shipping a format. - Tests: stability, obvious-fake data block, shape-matches-real-key (same prefix/env/separator/checksum layout), and that a preview does NOT verify. Full suite green, clippy clean.
- DESIGN doc: resolved B2 (config-level no_environment flag), B3 (ChecksumBits only; EntropyBytes/Mebibytes deferred), B5 (keyed checksum deferred); marked Part B complete except B5. - READMEs: add a 'Token format' section (separator/env/checksum-bits/example_key) and a configurable-format feature bullet.
Removed the example_key preview from Generator and ApiKeyManager and inlined the assemble() helper back into raw_key (it only existed to share format logic with example_key). Updated READMEs and the design-doc status note accordingly. Full suite green, clippy clean.
…epper) The manual Debug impl was redundant — the pepper is a SecureString (secrecy::SecretString), whose Debug redacts its contents. Derive Debug and keep a test asserting the pepper never appears in the output.
The default is now keyed HMAC-SHA256 instead of SHA-256. Since HMAC requires a
secret, this makes the zero-config path deliberately fail-closed: build() returns
ConfigError::EmptyPepper unless a pepper is supplied. Rationale: for high-entropy
keys a fast hash is sufficient (NIST SP 800-63B), and keying with a server-side
pepper additionally makes a leaked key database useless without the separately
stored pepper.
- Added ConfigBuilder::pepper(impl Into<SecureString>) as the ergonomic happy
path (equivalent to .hash(HashAlgo::HmacSha256 { pepper })).
- Default ConfigBuilder now carries HmacSha256 with an empty pepper; validate_hash
rejects it as EmptyPepper unless set. Opt out with .hash(HashAlgo::Sha256) or
.hash(HashAlgo::Argon2id(..)); high_security() still uses Argon2id.
- Updated all unit + integration tests to supply a test pepper (or an explicit
unkeyed/Argon2id algorithm); manager full_lifecycle now expects the
hmac-sha256$ stored-hash tag.
- Reworked README + crate-doc examples to show the pepper-based default and the
Sha256 opt-out.
Full suite green, clippy clean.
ssddOnTop
left a comment
There was a problem hiding this comment.
Review: architecture 👍, but one confirmed panic bug, one threat-model gap, and some "minimal" goals not yet met
I checked out the branch and verified locally: cargo fmt --check, cargo clippy --all-targets, and the full test suite (85 lib tests + integration + doctests) are all green. The layering is real — I verified generate and verify genuinely never import each other, and shared/checksum.rs is a clean extraction of the old verify → generator reach-around. Separate GenerateError/VerifyError, Ok(KeyStatus::Invalid) for wrong keys, #[from]/transparent error wrapping, self-describing hash tags, and ChecksumBits are all keepers. Dropping example_key() before merge was the right instinct.
That said, I found issues worth addressing before merge — details in the inline comments, summary here:
🐛 Bugs
- Confirmed overflow panic on attacker-controlled input in
verify_expiry(verify.rs:186). I reproduced it: a crafted key with expiry ≈i64::MAXpanics any build with overflow checks on (debug, or release withoverflow-checks = true). Unauthenticated remote DoS. Repro in the inline comment. (Carried over from oldvalidator.rs, but this PR rewrites the function — now is the time.) - PR description no longer matches the code: the summary says "
Sha256— fast, default", but commit 80b3c8d madeHmacSha256the default with a required pepper —ConfigBuilder::new().prefix("sk").build()now fails withEmptyPepper. That's a defensible security decision, but it's a breaking behavioral change and the PR body should advertise it accurately. - Several doc fragments still describe the Argon2-by-default era (inline comments on
generate.rs,verify.rs, README).
🔐 Security decision needed
- Tag-dispatch verification silently defeats the pepper under a DB-write threat model (
shared/hasher.rs). The pepper's pitch is "a leaked key DB alone can't verify keys" — but an attacker with DB write access can insertsha256$<hex>of a self-generated key and an HMAC-configured service will verify it. The migration story is good; it just needs its threat-model fine print (or an opt-in allowlist). Inline comment has options.
📦 Overdone / not minimal (the PR's own stated goal)
tailcall-validpulls inserde,serde_json,http,serde_path_to_error,derive_setters,wasm-bindgen(verified viacargo tree) — and thenConfigErrors::from_causesdiscards the.trace(...)field context, the library's one differentiating feature over a plainVec. A ~20-line hand-rolled accumulator produces byte-identical output with zero new deps.regex+lazy_staticcan both be dropped: ther"v\d+"regex is duplicated by a hand-rolledis_version_likeingenerate.rsanyway, andEnvironment::variants()(the only otherlazy_staticuser) has zero callers.- Smaller redundancies:
verify()parses the token twice and length-checks twice;Verifierconstructs a secondKeyHasherthe manager already built.
⚠️ API footguns (pick your battles)
.hash()/.pepper()silently overwrite each other — order-dependent builder in an otherwise report-everything design.Environment::Custom(String)pub field bypasses the normalization thatEnvironment::custom()applies.ChecksumSpec.lengthispuband denominated in hex chars — the exact unit footgunChecksumBitswas introduced to kill.- Error-taxonomy asymmetry in the expiry path (
Err(MalformedInput)vsOk(Invalid)for equally-hostile garbage).
🚦 CI
All PR checks are red (Lint, Build and Test, Analyze, SonarQube), each dying in 2–8s with no failed steps and no retrievable logs — smells like workflow/infra rather than code (everything passes locally). Worth diagnosing separately, but this shouldn't merge with an unexplained red pipeline.
Suggested pre-merge checklist
- Fix
verify_expiryoverflow (saturating_add) + regression test with near-i64::MAXexpiry - Document (or gate) the tag-dispatch/pepper-downgrade behavior
- Replace
tailcall-validwith a local accumulator, or justify the dep tree - Deduplicate version-like detection; drop
regex+lazy_static; delete unusedvariants() - Sync PR body + stale Argon2-era docs (README checksum diagram still says 16 hex chars; min is now 32)
- Optional: single
parse_tokenper verify, share theKeyHasher, builder conflict detection
None of this invalidates the architecture — items 5–6 actually complete the PR's stated minimalism goal.
|
|
||
| // Once expired beyond the grace window, stays expired even if the clock | ||
| // moves backwards. | ||
| if expiry_timestamp + grace < now { |
There was a problem hiding this comment.
🐛 Confirmed bug: overflow panic on attacker-controlled input (unauthenticated DoS in debug / overflow-checks = true builds).
expiry_timestamp comes straight out of the presented token. With expiry = i64::MAX - 3 (a valid canonical no-pad base64url encoding) and any non-zero grace, expiry_timestamp + grace overflows. The BLAKE3 checksum is unkeyed, so an attacker can compute it themselves — the crafted key sails through the checksum stage and panics here.
I reproduced it against this branch:
let hostile: i64 = i64::MAX - 3;
let exp_b64 = URL_SAFE_NO_PAD.encode(hostile.to_be_bytes());
let mut h = blake3::Hasher::new();
h.update(body.as_bytes()); // key body without real checksum
h.update(exp_b64.as_bytes());
let sum = h.finalize().to_hex()[..32].to_string();
let crafted = SecureString::from(format!("{body}.{exp_b64}.{sum}"));
let _ = m.verify(&crafted, stored_hash);thread '...' panicked at crates/api-keys-simplified/src/verify.rs:186:12:
attempt to add with overflow
In release (wrapping) it silently wraps negative and returns Invalid, which masks the bug. Fix:
if expiry_timestamp.saturating_add(grace) < now {This predates the PR (same expression in old validator.rs), but this PR rewrites the function — now is the moment. Please also add a regression test with a near-i64::MAX expiry.
| ); | ||
|
|
||
| let now = chrono::Utc::now().timestamp(); | ||
| let grace = self.grace_period.as_secs() as i64; |
There was a problem hiding this comment.
Related nit: as_secs() as i64 can itself go negative for absurd configured durations (Duration::from_secs(u64::MAX)), which would let expired keys verify (expiry + negative_grace < now flips). grace_period is the one builder field with no validation in ConfigBuilder::build() — a cheap range check there (or i64::try_from(...).unwrap_or(i64::MAX) here) would close this off.
| /// | ||
| /// Uses constant-time comparison for the digest algorithms; Argon2's verifier | ||
| /// is constant-time internally. | ||
| pub(crate) fn verify_key(&self, key_bytes: &[u8], stored_hash: &str) -> bool { |
There was a problem hiding this comment.
🔐 Security decision needed: tag-dispatch silently defeats the pepper under a DB-write threat model.
The pepper's advertised guarantee (docs, README, PR body) is "a leaked key database alone cannot be used to verify keys." But because dispatch is on the stored string's tag rather than the configured algorithm, an attacker with DB write access can insert sha256$<hex> of a key they generated themselves, and an HMAC-configured service will verify it — no pepper required. The migration feature doubles as a permanent algorithm-downgrade path.
The tests actively celebrate this (verify_dispatches_on_stored_prefix_not_config), so it's clearly intentional — it just needs its fine print. Options, cheapest first:
- (a) Document the caveat on
HashAlgo/ here: "tag dispatch means an attacker who can write to the hash column can bypass keyed hashing." - (b) Accept only the configured algo's tag by default, plus an explicit opt-in allowlist (e.g.
.also_accept(&[HashTag::Argon2id])) for migrations.
I'd do (a) now and consider (b) later; shipping it silently is the only wrong option.
Minor related note: an unknown/corrupted tag returns false, indistinguishable from "wrong key" — deliberate for timing-oracle reasons, but ops-wise a corrupted hash column becomes a silent lockout. Worth a sentence in the docs.
|
|
||
| impl ConfigErrors { | ||
| fn from_causes(causes: Vec<Cause<ConfigError, Field>>) -> Self { | ||
| ConfigErrors(causes.into_iter().map(|c| c.error).collect()) |
There was a problem hiding this comment.
📦 Main "keep it minimal" objection: tailcall-valid costs ~6 transitive deps and its one differentiating feature is discarded right here.
cargo tree confirms this dep pulls serde, serde_json, http, serde_path_to_error, derive_setters, and wasm-bindgen into a security-focused crate that previously had a lean tree — to validate a config builder.
And on this line, .map(|c| c.error) throws away every .trace("prefix") / .trace("checksum") field annotation — the applicative validator's only advantage over a plain Vec. The observable behavior of the whole Vc<A> + fuse + trace machinery reduces to "push errors into a Vec".
A hand-rolled accumulator produces byte-identical ConfigErrors with zero new deps:
let mut errors = Vec::new();
let prefix = validate_prefix(self.prefix, &mut errors);
let entropy = validate_entropy(self.entropy_bytes, &mut errors);
// ...
if errors.is_empty() { Ok(ValidatedConfig { .. }) } else { Err(ConfigErrors(errors)) }The accumulate-all-errors UX is genuinely good and worth keeping — the dependency is not. (If you do keep it, at least stop discarding the trace context; render it in Display as prefix: ....)
|
|
||
| /// True if `s` contains a `v<digits>` run (matches the config-layer rule for | ||
| /// version-like prefixes, kept local to avoid a cross-layer dependency). | ||
| fn is_version_like(s: &str) -> bool { |
There was a problem hiding this comment.
📦 Duplicated rule + two droppable deps. This hand-rolled is_version_like duplicates the r"v\d+" regex in config.rs. The comment says it's "kept local to avoid a cross-layer dependency" — but generate already imports config (that's the whole architecture: arrows point down; see the imports at the top of this file).
Suggestion: move is_version_like into config as pub(crate), use it for prefix validation too, and drop both regex and lazy_static from Cargo.toml. Two fewer deps, one source of truth for the rule, and the config-layer and generate-layer definitions can never drift apart.
| } | ||
|
|
||
| /// Full verification: checksum → Argon2 → expiry. | ||
| pub fn verify(&self, key: &SecureString, stored_hash: &str) -> Result<KeyStatus> { |
There was a problem hiding this comment.
Redundant work per verify() call (not a bug, just accidental duplication in a brand-new design):
- The token is parsed twice — once in
verify_checksum(), again inverify_hash_and_expiry()— and the key is length-checked twice. A singleparse_tokenup front whosePartsare passed to both stages would remove it. - Two lines up in
Verifier::new(line 73), the verifier builds its ownKeyHasherfrom config — whileApiKeyManager::newalready constructed an identical one two lines earlier and passes it around separately. Passing the hasher intoVerifier::new(or having the manager own a single one) removes a divergence trap: today nothing guarantees the manager's hasher and the verifier's hasher agree beyond both being derived from the same config clone.
| // ApiKey typestate | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// The Argon2 hash (PHC format) plus a stable key identifier. |
There was a problem hiding this comment.
Stale doc (Argon2-era): Hash is no longer necessarily "The Argon2 hash (PHC format)" — with the new default it's an hmac-sha256$… string, which is deterministic (no per-hash salt), so "changes each hash due to random salt" below is also wrong for the default. Suggest: "The stored-hash string (self-describing: sha256$…, hmac-sha256$…, or $argon2id$… PHC) plus a stable key identifier."
| self.checksum.is_some() | ||
| } | ||
|
|
||
| /// Full verification: checksum → Argon2 → expiry. |
There was a problem hiding this comment.
Stale doc: "checksum → Argon2 → expiry" — Argon2 is no longer the (only or default) hash. "checksum → hash verify → expiry" matches the dispatch behavior described in the module docs above.
| - **Hash** keys using Argon2id (memory-hard, OWASP recommended) | ||
| - **Hash** keys with a pluggable algorithm — keyed **HMAC-SHA256** (default, | ||
| needs a pepper), fast unkeyed **SHA-256**, or memory-hard **Argon2id** | ||
| - **Checksum** keys with BLAKE3 for fast DoS protection (2900x speedup) |
There was a problem hiding this comment.
Two doc issues in this README:
-
The 2900x DoS framing is stale under the new default. "2900x faster" compares BLAKE3 (~µs) to Argon2 (~300ms). With the new HMAC-SHA256 default, full hash verification is already microseconds, so the checksum's remaining value under the default config is skipping a DB lookup via
verify_checksum()(and staying cheap if someone opts into Argon2id). That's still real — but a different story than the one told here and in the "📊 DoS Protection" section below. Recommend reframing rather than deleting. -
The "Key Format" diagram further down (lines ~96–107, unchanged by this PR) is now wrong: it says "BLAKE3 (recommended, 16 hex chars)" and the examples end in 16-hex-char checksums (
.a1b2c3d4e5f6g7h8). The new minimum is 128 bits = 32 hex chars — 16 is no longer even representable viaChecksumBits. Since this PR changed the checksum rules, the diagram should follow.
| let decoded = URL_SAFE_NO_PAD | ||
| .decode(expiry) | ||
| .map_err(|_| VerifyError::MalformedInput)?; | ||
| let expiry_timestamp = i64::from_be_bytes( |
There was a problem hiding this comment.
Error-taxonomy asymmetry: an unparseable token → Ok(KeyStatus::Invalid) (per the crate's own "wrong key is not an error" rule), but a token that parses and then fails canonical base64 decode of the expiry → Err(VerifyError::MalformedInput). Both inputs are equally attacker-controlled garbage that survived the checksum only if the attacker computed it deliberately.
Not a bug, but callers now have to handle hostile input on both the Ok(Invalid) and Err(_) paths. Collapsing this to Ok(Invalid) (keeping Err only for the length-limit DoS guards, which genuinely indicate caller/infra issues) would make the contract stated on VerifyError ("only oversized / genuinely malformed inputs surface here") simpler and truer — right now InputTooLong is the only variant a well-behaved caller can ever see... except this one.
Summary
Restructures the crate into a clean, layered architecture and makes the storage-hash algorithm and token format pluggable. Pre-1.0, so this intentionally includes breaking API changes.
Architecture
Strict, one-directional dependency tree so the
generateandverifypaths never import each other:ConfigBuildervalidates everything inbuild()and, viatailcall-valid, reports all errors at once (ConfigErrors) instead of failing on the first.GenerateErrorvsVerifyError(plusConfigErrors/InitError). A wrong key isOk(KeyStatus::Invalid), not an error.shared/checksum.rs;generateappends,verifychecks, via the same type.#[from]/transparentrather than stringifying them.Pluggable hashing (
HashAlgo)Sha256— fast, default (high-entropy keys only need an approved fast hash per NIST SP 800-63B).HmacSha256 { pepper }— fast + keyed; recommended for production (DB-leak resistant).Argon2id(Argon2Params)— memory-hard, opt-in; used byhigh_security().Stored hashes are self-describing (
sha256$…,hmac-sha256$…,$argon2id$…), so verification dispatches on the stored value — a database can hold hashes from multiple algorithms during a migration.Token-format ergonomics
Separator::Underscore(Stripe/GitHubsk_live_…style).Environment::Custom(String)andConfigBuilder::no_environment()(fold role/env into the prefix).ChecksumBitsnewtype — checksum length is now specified in bits (fixes the old hex-chars-vs-bits footgun).Breaking changes
ApiKeyManagerV0→ApiKeyManager; construct viaApiKeyManager::new(ConfigBuilder::….build()?).HashSpec→Argon2Params;hash_params(m,t,p)→hash(HashAlgo::…).$argon2id$hashes still verify (prefix dispatch).checksum(algo, usize)→checksum(algo, ChecksumBits).Testing
Full suite green (84 lib + integration + doctests),
clippy --all-targetsclean.Draft: opening for early review; commit history may be squashed before merge.