Store API keys hashed at rest instead of plaintext #18
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
API key values are stored plaintext in the
api_keystable.db.validate_api_keydoes a literalWHERE key_value = ?match, andcreate_api_keyinserts the raw value — nothing is hashed. So a leak of just the SQLite file hands an attacker every live API key, including the master key.This is inconsistent with the rest of the secret-handling posture: Cloudflare API tokens and ACME private keys are AES-256-GCM encrypted at rest (
ENCRYPTION_KEY,crypto.rs), but the API keys — equally sensitive bearer credentials — are not protected at all. The "Save this key securely! It cannot be retrieved again" message printed at first boot is also misleading:SELECT key_value FROM api_keys WHERE key_type='master'recovers it.Suggested fix
key_valuefromlist_api_keys(it would only be a hash anyway).Coordination
Depends on / coordinates with the
MASTER_API_KEYenv var work (#B below): that path must hash the provided value on ingestion too. The env var always carries the raw key; storage is the implementation detail that changes here.Security hardening — not urgent, but a real gap for an off-the-shelf product. Surfaced 2026-08-07.
Implemented in
7f68b22.Approach: SHA-256 hash at rest, stored as
sha256:<hex>(plain SHA-256 is sufficient for high-entropy random tokens — a slow KDF adds nothing). Hashing happens at the DB boundary —create_api_key,set_api_key_value,validate_api_keyall hash their raw input, so every caller keeps passing raw.rehash_plaintext_api_keys()upgrades any pre-existing plaintext rows in place (idempotent; skips already-hashed), run at startup before master-key seeding so both sides of thehash(env) == stored_hashcomparison are hashes.Coordination with
MASTER_API_KEY(the #B path):decide_master_keynow compareshash(env) == stored_hashwhileSeed/Rotatestill carry the raw value (storage hashes on write). Startup rejects aMASTER_API_KEYbeginning with thesha256:sentinel, so a raw key can't be mistaken for already-hashed and silently locked out.No operator reissue — presented raw keys still validate after the rehash.
list_api_keysno longer returnskey_value(#[serde(skip_serializing)]); the raw key is shown once at creation via the separateCreateKeyResponse. The first-boot "Save this key securely! It cannot be retrieved again" message is now actually true (it was previously recoverable viaSELECT key_value).Known properties to be aware of:
WHERE key_value = <raw>match) can't validate stored keys — service keys stay hashed; the master key only self-heals ifMASTER_API_KEYis set. Reverting needs a pre-rehash DB snapshot, not just an image-tag change. (Noted for devinfra, which runs:develop, in the deployment-topology memory.)ApiKey.key_valueis now write-only over serde (Deserialize still present but unused) — a future endpoint accepting anApiKeybody would be asymmetric. Inert today; flagged so it isn't a surprise.Tests: hash determinism/format/detection; create stores hash not raw + raw still validates; rehash one-shot + idempotent + heals a simulated legacy plaintext row; rotation stores a hash;
decide_master_keyhash-comparison branches; session-login e2e unchanged. Full suite green (196), clippy + fmt clean.Unblocks #24 (passkeys), whose ticket noted it sensibly lands after this.