Skip to content

Replay protection and backup code hardening (v1.1.0) - #19

Merged
amansoomro062 merged 1 commit into
mainfrom
fix/replay-and-backup-codes
Aug 14, 2026
Merged

Replay protection and backup code hardening (v1.1.0)#19
amansoomro062 merged 1 commit into
mainfrom
fix/replay-and-backup-codes

Conversation

@amansoomro062

Copy link
Copy Markdown
Owner

Fixes #7 and #8, the two security findings from the pre-launch review. Version is bumped to 1.1.0, so merging this publishes to npm through the new release pipeline.

verifyTotpWithDelta (#7)

New function returning { valid, delta, step }. step is the absolute time step the code matched; storing it per user and requiring each login to beat it prevents replay within the drift window, which RFC 6238 requires. The README login flow now shows this as the default pattern, with a totp_last_step column.

Deliberate decision: verifyTotp keeps returning a boolean. Changing its return type to an object would make if (await verifyTotp(...)) always pass for existing users, a silent auth bypass. The rich result lives in a new function instead, so nothing existing can break.

Every candidate in the window is still computed and compared in constant time with no early exit. If a code matches more than one step (a 1 in 10^digits coincidence), the earliest step is reported.

Backup codes (#8)

  • Default length 8 -> 10 characters (40 -> 50 bits of entropy)
  • Digests can be keyed: generateBackupCodes({ key }) uses HMAC-SHA-256, so leaked digests are useless without the key, matching the vault's threat model
  • New verifyBackupCode(input, hashed, { key }): normalises case, dashes, and whitespace, compares every stored digest in constant time with no early exit, and returns { valid, remaining } with the consumed digest removed, so the single-use rule is built into the API rather than left to the integrator
  • Digests stored by v1.0.x (grouped form, unkeyed) still verify through a legacy fallback, so upgrading breaks nobody

Tests

116 passing, 16 new: delta and step values across the window, the last-used-step replay pattern end to end, keyed vs unkeyed digests, input normalisation, single-use consumption including duplicate digests, and a v1.0.x legacy digest regression test.

verifyTotpWithDelta reports the matched time step so callers can store a
last-used step per user and reject replays, which RFC 6238 requires and a
bare boolean cannot support. verifyTotp keeps its boolean return: changing
it to an object would make every existing truthiness check silently pass.
The README login flow now shows the replay-safe pattern as the default.

Backup codes: default length 8 -> 10 characters (40 -> 50 bits), digests
can be keyed with HMAC-SHA-256 so a leaked database row cannot be
brute-forced offline, and verifyBackupCode normalises user input (case,
dashes, whitespace), compares every digest in constant time with no early
exit, and returns the remaining list with the consumed digest removed so
single-use is built into the API. Digests stored by v1.0.x (grouped form,
unkeyed) still verify via a legacy fallback.

Closes #7, closes #8
@amansoomro062
amansoomro062 merged commit 200e5e2 into main Aug 14, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

verifyTotp: return the matched step so callers can implement replay protection

1 participant