Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 122 additions & 11 deletions docs/core/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 10<sup>6</sup>, 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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
34 changes: 30 additions & 4 deletions docs/core/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down