feat(crypto): recovery codes kept under the instance's key

A code holds 40 bits, which plain SHA-256 hashes in a leaked database
would give up to guessing; HMAC-SHA256 under a key of the instance kept
outside the database (recoveryCodeKey, at least 32 characters) gives
nothing to guess against without it. Decided 20 Oct 2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 12:28:56 +02:00
commit 7ad0f92f23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -140,9 +140,11 @@ The second factor, in `package:halkyon_base_server/base.dart`:
so no code is accepted twice; `TotpAccepted.step` is the step to keep before answering.
Refusals: `malformed`, `wrong`, `reused`. `totpCode` makes a code (SHA-256 and SHA-512
for RFC 6238's vectors).
- `RecoveryCodes.generate()`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
email sign-in's codes), shown once, kept as SHA-256 hashes; `useRecoveryCode(typed,
hashes)` compares every hash in constant time and gives the hashes left
- `RecoveryCodes.generate(key)`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
email sign-in's codes), shown once, kept as HMAC-SHA256 under the instance's key
(`recoveryCodeKey`, at least 32 characters, kept outside the database: a code's 40 bits
in a leaked database give nothing to guess against without it); `useRecoveryCode(typed,
hashes, key)` compares every hash in constant time and gives the hashes left
(`RecoveryCodeAccepted.remaining`), so each code works once.
## Taking it into a host