Skip to content

Refactor: layered architecture + pluggable hashing & token format - #29

Draft
ssddOnTop wants to merge 17 commits into
mainfrom
refactor/architecture-and-pluggable-hashing
Draft

Refactor: layered architecture + pluggable hashing & token format#29
ssddOnTop wants to merge 17 commits into
mainfrom
refactor/architecture-and-pluggable-hashing

Conversation

@ssddOnTop

Copy link
Copy Markdown
Member

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 generate and verify paths never import each other:

manager            (orchestrator: ApiKeyManager)
  ├── generate     Generator, ApiKey<Hash>, GenerateError
  └── verify       Verifier, KeyStatus, VerifyError
        shared     checksum · hasher · token_parser · secure
          config   primitives + ConfigBuilder + ValidatedConfig
  • Config up front: a single ConfigBuilder validates everything in build() and, via tailcall-valid, reports all errors at once (ConfigErrors) instead of failing on the first.
  • Separate error types: GenerateError vs VerifyError (plus ConfigErrors / InitError). A wrong key is Ok(KeyStatus::Invalid), not an error.
  • Shared, no cross-imports: checksum logic was extracted out of the generator into shared/checksum.rs; generate appends, verify checks, via the same type.
  • Errors wrap upstream causes with #[from]/transparent rather 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 by high_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/GitHub sk_live_… style).
  • Environment::Custom(String) and ConfigBuilder::no_environment() (fold role/env into the prefix).
  • ChecksumBits newtype — checksum length is now specified in bits (fixes the old hex-chars-vs-bits footgun).

Breaking changes

  • ApiKeyManagerV0ApiKeyManager; construct via ApiKeyManager::new(ConfigBuilder::…​.build()?).
  • HashSpecArgon2Params; hash_params(m,t,p)hash(HashAlgo::…).
  • Default hash is now SHA-256 (was Argon2id). Old $argon2id$ hashes still verify (prefix dispatch).
  • checksum(algo, usize)checksum(algo, ChecksumBits).

Testing

Full suite green (84 lib + integration + doctests), clippy --all-targets clean.

Draft: opening for early review; commit history may be squashed before merge.

ssddOnTop added 17 commits July 8, 2026 10:35
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 ssddOnTop left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Confirmed overflow panic on attacker-controlled input in verify_expiry (verify.rs:186). I reproduced it: a crafted key with expiry ≈ i64::MAX panics any build with overflow checks on (debug, or release with overflow-checks = true). Unauthenticated remote DoS. Repro in the inline comment. (Carried over from old validator.rs, but this PR rewrites the function — now is the time.)
  2. PR description no longer matches the code: the summary says "Sha256 — fast, default", but commit 80b3c8d made HmacSha256 the default with a required pepper — ConfigBuilder::new().prefix("sk").build() now fails with EmptyPepper. That's a defensible security decision, but it's a breaking behavioral change and the PR body should advertise it accurately.
  3. Several doc fragments still describe the Argon2-by-default era (inline comments on generate.rs, verify.rs, README).

🔐 Security decision needed

  1. 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 insert sha256$<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)

  1. tailcall-valid pulls in serde, serde_json, http, serde_path_to_error, derive_setters, wasm-bindgen (verified via cargo tree) — and then ConfigErrors::from_causes discards the .trace(...) field context, the library's one differentiating feature over a plain Vec. A ~20-line hand-rolled accumulator produces byte-identical output with zero new deps.
  2. regex + lazy_static can both be dropped: the r"v\d+" regex is duplicated by a hand-rolled is_version_like in generate.rs anyway, and Environment::variants() (the only other lazy_static user) has zero callers.
  3. Smaller redundancies: verify() parses the token twice and length-checks twice; Verifier constructs a second KeyHasher the manager already built.

⚠️ API footguns (pick your battles)

  1. .hash() / .pepper() silently overwrite each other — order-dependent builder in an otherwise report-everything design.
  2. Environment::Custom(String) pub field bypasses the normalization that Environment::custom() applies.
  3. ChecksumSpec.length is pub and denominated in hex chars — the exact unit footgun ChecksumBits was introduced to kill.
  4. Error-taxonomy asymmetry in the expiry path (Err(MalformedInput) vs Ok(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_expiry overflow (saturating_add) + regression test with near-i64::MAX expiry
  • Document (or gate) the tag-dispatch/pepper-downgrade behavior
  • Replace tailcall-valid with a local accumulator, or justify the dep tree
  • Deduplicate version-like detection; drop regex + lazy_static; delete unused variants()
  • Sync PR body + stale Argon2-era docs (README checksum diagram still says 16 hex chars; min is now 32)
  • Optional: single parse_token per verify, share the KeyHasher, 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔐 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:

  1. (a) Document the caveat on HashAlgo / here: "tag dispatch means an attacker who can write to the hash column can bypass keyed hashing."
  2. (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())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 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> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant work per verify() call (not a bug, just accidental duplication in a brand-new design):

  1. The token is parsed twice — once in verify_checksum(), again in verify_hash_and_expiry() — and the key is length-checked twice. A single parse_token up front whose Parts are passed to both stages would remove it.
  2. Two lines up in Verifier::new (line 73), the verifier builds its own KeyHasher from config — while ApiKeyManager::new already constructed an identical one two lines earlier and passes it around separately. Passing the hasher into Verifier::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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two doc issues in this README:

  1. 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.

  2. 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 via ChecksumBits. 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(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant