From 7edc3f049957928205b9fa9c1d0820972497a702 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 14:56:46 +0530 Subject: [PATCH] docs(security): document the at-rest encryption key advisory The at-rest key was keyed to --jwt-secret, which RSA/ECDSA installs legitimately leave empty, so TOTP seeds and OTP digests were protected by a publicly computable constant. Documents affected versions (2.2.1-rc.2 through 2.4.0-rc.13), who is and is not affected, and the remediation. Also corrects the existing at-rest and key-rotation sections, which still named --jwt-secret as the encryption key, and adds the --encryption-key flag reference. --- docs/core/security.md | 133 ++++++++++++++++++++++++++++++++++--- docs/core/server-config.md | 34 ++++++++-- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/docs/core/security.md b/docs/core/security.md index 7cbfff4..f9d4ec3 100644 --- a/docs/core/security.md +++ b/docs/core/security.md @@ -42,10 +42,97 @@ batch, you must address these two items before restarting: See [Trusted proxies](#trusted-proxies) below for details. +Upgrading to **2.4.0** adds one more, and it applies only if you use +RSA/ECDSA JWT signing: + +3. **`--encryption-key` is now required when `--jwt-secret` is empty.** + The at-rest key for TOTP secrets and OTP digests is now + `--encryption-key`, which falls back to `--jwt-secret` when unset. If + both are empty — the normal state for an `RS*`/`ES*` install, where + `--jwt-secret` is not used — the server now **refuses to start** + instead of encrypting with a key derived from an empty string. + + ```bash + ./authorizer --encryption-key="$(openssl rand -hex 32)" ... + ``` + + HMAC (`HS256`/`HS384`/`HS512`) deployments are **unaffected**: the + fallback resolves to the same `--jwt-secret` value used before, so + existing TOTP enrolments and OTPs keep working with no config change. + + See [Security advisory](#security-advisory-empty-at-rest-key-on-rsaecdsa-installs). + Everything else in this document is opt-in or already on by default. --- +## Security advisory: empty at-rest key on RSA/ECDSA installs + +**Severity:** High  ·  **Fixed in:** 2.4.0  ·  +**Affects:** RSA/ECDSA (`RS*`/`ES*`) deployments only + +### Affected versions + +`2.2.1-rc.2` through `2.4.0-rc.13` inclusive — specifically 2.2.1, +2.3.0, every 2.3.0/2.4.0 release candidate, and no release before +2.2.1-rc.2. + +| JWT type | Affected? | +| --- | --- | +| `HS256` / `HS384` / `HS512` | **No.** `--jwt-secret` is mandatory, so the key was always a real secret. | +| `RS*` / `ES*` | **Yes**, whenever `--jwt-secret` was not also set (the normal configuration). | +| Before 2.2.1-rc.2 (any JWT type) | Not affected by *this* issue — TOTP secrets and OTPs were stored in plaintext, which the 2.2.1 hardening pass addressed. | + +### What went wrong + +The at-rest key was wired directly to `--jwt-secret`. That flag is only +required for HMAC signing — an `RS*`/`ES*` install legitimately leaves it +empty. The key therefore resolved to the empty string, and: + +- **TOTP shared secrets** were encrypted with AES-256-GCM under a key + derived by HKDF-SHA256 from empty keying material with no salt and a + fixed info string. All three inputs are public, so the resulting key is + a **fixed, publicly computable constant**. Anyone with a database dump + and the open-source code can decrypt every TOTP seed. +- **OTP digests** (email/SMS verification, signup, and **password reset**) + were HMAC-SHA256 keyed with the empty string. Because the attacker knows + the key and the code space is only 106, a stored digest is + reversible by brute force in **well under a second**, yielding + outstanding password-reset codes and with them account takeover. + +The failure was **silent**. Encryption succeeded, rows carried the normal +`enc:v1:` prefix, digests were the expected length, and nothing was logged. +An operator auditing the database would see ciphertext and reasonably +conclude the data was protected. + +### The fix + +`--encryption-key` is now a distinct configuration value, resolved in +`Config.Finalize()` as `--encryption-key` → `--jwt-secret` → **startup +error**. The check is deliberately not scoped to TOTP: the same key +protects password-reset OTPs, which are written regardless of whether +TOTP is enabled, so `--disable-totp-login` does not remove the +requirement. + +### What to do + +**HMAC deployments:** nothing. Optionally set a dedicated +`--encryption-key` to decouple it from your signing key — see +[Key rotation](#key-rotation). + +**RSA/ECDSA deployments:** + +1. Set `--encryption-key` to a strong random value. The server will not + start without it. +2. **Treat previously stored TOTP secrets as compromised.** They were + encrypted under a public constant, so anyone who obtained a database + copy while the affected versions were running can read them. Setting a + new key also makes them undecryptable, and there is no re-encryption + path — affected users must **re-enrol** their authenticator app. +3. Outstanding OTPs need no action; they expire within minutes. + +--- + ## Admin authentication ```bash @@ -313,12 +400,21 @@ No flags. The protection applies to: OTP and TOTP secrets are now protected at rest: - **OTPs (email/SMS one-time codes):** stored as HMAC-SHA256 digests - keyed by `--jwt-secret`. The verifier hashes the candidate and + keyed by `--encryption-key`. The verifier hashes the candidate and compares constant-time against the stored digest. The plaintext is only ever held in memory long enough to send the email/SMS body. - **TOTP shared secrets (authenticator app):** encrypted at rest with - AES-256-GCM (using HKDF-derived keys from `--jwt-secret`) and stored - with an `enc:v1:` prefix. + AES-256-GCM (using HKDF-derived keys from `--encryption-key`) and + stored with an `enc:v1:` prefix. + +`--encryption-key` falls back to `--jwt-secret` when unset, so HMAC +deployments need no new configuration. When **both** are empty the server +refuses to start — see the +[security advisory](#security-advisory-empty-at-rest-key-on-rsaecdsa-installs) +for why. + +A single global key is used, with a fresh random nonce per encryption; +the key is not derived per user. ### Migration @@ -357,18 +453,33 @@ this is a non-issue — there are no other replicas to disagree with. ### Key rotation -Both OTP and TOTP at-rest protection are keyed by `--jwt-secret`. -Rotating `--jwt-secret` will lock out every user with an enrolled TOTP -authenticator until they re-enrol, because the existing ciphertext can -no longer be decrypted. If you must rotate the JWT secret, plan a -TOTP re-enrolment campaign (or a temporary fallback path) before doing so. -The server logs an explicit error on every TOTP validation that fails -to decrypt: +Both OTP and TOTP at-rest protection are keyed by `--encryption-key`. +Changing it will lock out every user with an enrolled TOTP authenticator +until they re-enrol, because the existing ciphertext can no longer be +decrypted. There is **no re-encryption path**: the at-rest key cannot be +rotated in place today. + +The server logs an explicit error on every TOTP validation that fails to +decrypt: ``` -failed to decrypt stored TOTP secret; check that --jwt-secret has not changed since enrollment +failed to decrypt stored TOTP secret; check that --encryption-key (or --jwt-secret, if no encryption key is set) has not changed since enrollment ``` +> **Set `--encryption-key` explicitly, even on HMAC deployments.** +> If you leave it unset it falls back to `--jwt-secret`, which couples +> two keys that have very different lifecycles. A JWT signing secret is +> meant to be rotatable — Authorizer even ships +> [secondary-key rotation](./server-config#5-jwt-configuration) for it — +> and rotation is cheap, because tokens simply expire and users log in +> again. But while the two are the same value, **rotating `--jwt-secret` +> silently destroys every TOTP enrolment and invalidates outstanding +> OTPs.** +> +> Setting a separate `--encryption-key` from day one costs nothing and +> removes the coupling permanently. Doing it *later*, once users have +> enrolled, requires those users to re-enrol. + --- ## Multi-factor authentication (MFA) & Passkeys diff --git a/docs/core/server-config.md b/docs/core/server-config.md index 4fefe32..f8139bd 100644 --- a/docs/core/server-config.md +++ b/docs/core/server-config.md @@ -222,10 +222,36 @@ Additional flags: In v2, the `_generate_jwt_keys` mutation is deprecated and returns an error; configure keys **only via flags**. -> **Note on key rotation:** `--jwt-secret` is also used to encrypt TOTP shared -> secrets at rest and to HMAC OTPs. Rotating it will lock out every user with -> an enrolled TOTP authenticator until they re-enrol. See -> [OTP and TOTP at rest](./security#otp-and-totp-at-rest). +### Encryption at rest + +`--encryption-key` is the symmetric key used to protect secrets the server must +read back later: TOTP shared secrets (AES-256-GCM) and OTP digests +(HMAC-SHA256) for email/SMS verification, signup, and password reset. + +```bash +./authorizer \ + --encryption-key="$(openssl rand -hex 32)" \ + ... +``` + +| Configuration | Behaviour | +| --- | --- | +| `--encryption-key` set | Used as-is. **Recommended.** | +| Unset, `--jwt-secret` set (HMAC installs) | Falls back to `--jwt-secret`. Backwards compatible. | +| Both unset (typical `RS*`/`ES*` install) | **Server refuses to start.** An empty key derives a publicly computable constant. | + +**Breaking change in 2.4.0:** the third row previously started normally and +encrypted with that public constant. RSA/ECDSA deployments must now set +`--encryption-key`. HMAC deployments are unaffected. See the +[security advisory](./security#security-advisory-empty-at-rest-key-on-rsaecdsa-installs) +for affected versions and remediation. + +> **Set it explicitly rather than relying on the fallback.** While +> `--encryption-key` and `--jwt-secret` are the same value, rotating your JWT +> signing secret — a routine, cheap operation on its own — will silently lock +> out every user with an enrolled TOTP authenticator, because the at-rest key +> changes with it and there is no re-encryption path. See +> [Key rotation](./security#key-rotation). ---