2.2 KiB
recovery/verify
Purpose
Verify a user-supplied recovery code against a list of stored hashes.
Constant-time over the full set — every entry is evaluated even after a
match is found so loop-position timing does not leak which code matched.
On success the returned codeIndex lets the caller mark that hash as used
(recovery codes are single-use).
Features
- Constant-time across the full hash list.
- Argon2id re-derivation using the per-hash stored parameters.
- Returns
{ valid, codeIndex }discriminated union. - Input normalisation — separators stripped, upper-cased — so
aaaa-bbbbmatches the originalAAAABBBBform regardless of how the user types it.
Usage: Basic Example
import { verifyRecoveryCode } from "@suaveplan/otp";
const r = await verifyRecoveryCode("aaaa-bbbb", user.recoveryHashes);
if (r.valid) markUsed(user.recoveryHashes[r.codeIndex]);
Usage: Advanced Example
import { verifyRecoveryCode } from "@suaveplan/otp";
// Drop the matched hash from the list atomically; subsequent attempts will
// miss even if the same code is replayed.
const r = await verifyRecoveryCode(input, hashes);
if (r.valid) {
await db.transaction(async (tx) => {
await tx.removeHash(user.id, hashes[r.codeIndex]);
});
}
API Reference
verifyRecoveryCode(input, hashedCodes): Promise<RecoveryVerifyResult>— returns{ valid: true; codeIndex }on match,{ valid: false; codeIndex: null }otherwise.
Implementation Notes
Argon2id is intentionally slow — verifying a list of N hashes runs N derivations. The constant-time loop completes regardless of where the match is so timing does not reveal list position. Production callers typically cap the list at 10 codes; below that the wall-clock cost is bounded and acceptable for an interactive recovery flow.
Each list entry carries its own Argon2id parameters in the encoded form so parameter rotation works without re-hashing the entire user base: as the threat model evolves, new codes can be hashed with stronger parameters while older codes verify against their original parameters. The hash format is self-describing precisely so this rotation is not a breaking change. Callers should still re-hash on next-use to migrate stragglers forward.