<!--
SPDX-FileCopyrightText: 2026 Alembic Pty Ltd

SPDX-License-Identifier: MIT
-->

# Recovery Code Security

This document explains the security model behind the recovery code strategy,
including hashing trade-offs, entropy requirements, and verification mechanics.

## How Recovery Codes Work

Recovery codes are generated by the system, displayed once to the user, and
stored as hashed values. When a user authenticates with a recovery code:

1. The submitted code is hashed
2. The hash is matched against stored values
3. On match, the code record is deleted (single-use enforcement)
4. The user is authenticated

Plaintext codes are never stored. They exist only in memory during generation
and are returned to the caller via action metadata.

## Hash Provider Selection

The recovery code strategy supports pluggable hash providers. The choice of
provider involves a trade-off between code length and offline attack resistance.

### SHA-256 (Default)

SHA-256 is a fast, deterministic hash. Given the same input, it always produces
the same output. This enables:

- **Atomic verification** — hash the input once, then use a single database
  query to find and delete the matching record. No race conditions.
- **Near-instant verification** — SHA-256 takes microseconds, regardless of how
  many codes are stored.

The cost: because SHA-256 is fast, an attacker with database access can
brute-force short codes quickly. The defence is to use codes with sufficient
entropy (the strategy enforces a minimum of 60 bits).

### Bcrypt / Argon2

These are slow, salted hashes designed for passwords. Each hash uses a random
salt, so the same input produces different output each time. This means:

- **No atomic verification** — verification requires loading all stored hashes
  and checking each one individually.
- **Slower verification** — up to ~100ms per hash comparison, so verifying
  against 10 stored codes may take ~1 second.
- **Strong offline resistance** — even short codes take years to brute-force.

The benefit: codes can be shorter (e.g. 8 characters) while remaining secure
against offline attacks.

## Entropy and Code Length

The strategy calculates the entropy of the configured code format and validates
it against the hash provider's declared `minimum_entropy()`.

**Entropy formula:** `code_length * log2(alphabet_size)`

| Alphabet | Size | Code Length | Entropy (bits) | SHA-256 Safe? |
|----------|------|-------------|----------------|---------------|
| A-Z, 0-9 | 36 | 8 | ~41 | No |
| A-Z, 0-9 | 36 | 10 | ~52 | No |
| A-Z, 0-9 | 36 | 12 | ~62 | Yes |
| A-Z, 0-9 | 36 | 16 | ~83 | Yes |
| A-Z (no 0-9) | 26 | 12 | ~56 | No |
| A-Z, a-z, 0-9 | 62 | 10 | ~60 | Borderline |

The default configuration (36-character alphabet, 12-character codes) provides
~62 bits of entropy. At SHA-256 speeds (~10 billion hashes/sec on a modern GPU),
this would take approximately 15 years to brute-force.

Bcrypt and Argon2 declare `minimum_entropy()` of 0, meaning any code length is
accepted. Their computational cost (~100ms per attempt) provides the protection
instead.

## Atomic Verification

The strategy uses different verification approaches depending on the hash
provider's `deterministic?()` callback:

### Deterministic Hash (SHA-256)

```
1. Hash the submitted code: SHA256("AB3KMN7QR2XY") → "c95b6120..."
2. DELETE FROM recovery_codes WHERE user_id = ? AND code = "c95b6120..."
3. If a row was deleted → code was valid
4. If no rows deleted → code was invalid
```

This is a single atomic database operation. Two concurrent requests with the
same code cannot both succeed — only one `DELETE` will find the row.

### Non-Deterministic Hash (Bcrypt)

```
1. SELECT * FROM recovery_codes WHERE user_id = ? FOR UPDATE
2. For each stored hash: bcrypt_verify(submitted_code, stored_hash)
3. If match found: DELETE FROM recovery_codes WHERE id = ?
4. Pad remaining iterations with simulate() for constant time
```

The `FOR UPDATE` lock serialises concurrent requests at the database level,
preventing two requests from matching the same code. The iteration count is
padded to `recovery_code_count` to prevent timing-based information leakage
about how many codes remain.

## Brute Force Protection

Brute force protection is mandatory. Without it, an attacker who obtains a
session could attempt to guess recovery codes. The three options mirror those
available for the TOTP strategy:

- **Custom preparation** — implement your own rate limiting or lockout logic
- **Rate limiting** — use the `AshRateLimiter` extension to limit attempts
- **Audit log** — use an audit log add-on to track and limit failed attempts
