Backup code generation, hashing, and atomic consumption.
Backup codes are single-use recovery codes displayed to the user during
MFA enrollment. They are stored as SHA-256 hashes in the database and
consumed atomically via UPDATE ... WHERE used_at IS NULL.
Format
Codes use XXXX-XXXX format (8 numeric digits, dash-separated). Verification strips dashes and spaces before hashing (D-12).
Security Properties
- Generated with
:crypto.strong_rand_bytes/1(cryptographically secure) - Stored as SHA-256 hex hashes -- never retrievable after initial display (D-13, D-20)
- Consumed atomically to prevent race conditions (D-16)
- Hash-to-hash comparison in DB sidesteps timing attacks (Pitfall 5)
Summary
Functions
Appends backup-code replacement steps (delete_all + insert_all) to an
Ecto.Multi.
Atomically consumes a backup code for the given user.
Generates a list of {formatted_code, sha256_hex_hash} tuples.
Normalizes a submitted code (strips dashes/spaces) and returns its SHA-256 hex hash.
Regenerates backup codes for a user.
Returns the count of unused backup codes for a user.
Functions
@spec append_replace_steps( Ecto.Multi.t(), module(), term(), pos_integer(), DateTime.t() ) :: {Ecto.Multi.t(), [String.t()]}
Appends backup-code replacement steps (delete_all + insert_all) to an
Ecto.Multi.
Returns {multi, formatted_codes} so callers can wrap the operation in
repo.transaction/1 alongside other steps (for example audit rows).
now defaults to DateTime.utc_now/0 and is used for each row's
inserted_at, matching confirm_enrollment/5 bulk inserts.
@spec consume(module(), module(), term(), String.t()) :: {:ok, :consumed} | {:error, :invalid_backup_code}
Atomically consumes a backup code for the given user.
Normalizes the submitted code, hashes it, and performs an atomic
UPDATE ... SET used_at = NOW() WHERE hashed_code = ? AND used_at IS NULL.
Returns {:ok, :consumed} if a matching unused code was found and consumed,
or {:error, :invalid_backup_code} if no match.
Parameters
repo- The Ecto repo modulebackup_code_schema- The generated backup code Ecto schema moduleuser_id- The user's IDsubmitted_code- The raw code submitted by the user
@spec generate(pos_integer()) :: [{String.t(), String.t()}]
Generates a list of {formatted_code, sha256_hex_hash} tuples.
Each code is an 8-digit number formatted as "XXXX-XXXX". The hash is the SHA-256 hex digest of the normalized (digits-only) code.
Examples
iex> codes = Sigra.MFA.BackupCodes.generate(8)
iex> length(codes)
8
Normalizes a submitted code (strips dashes/spaces) and returns its SHA-256 hex hash.
Examples
iex> Sigra.MFA.BackupCodes.hash("1234-5678")
Sigra.MFA.BackupCodes.hash("12345678")
@spec regenerate(module(), module(), term(), pos_integer()) :: {:ok, [String.t()]}
Regenerates backup codes for a user.
Deletes all existing codes and inserts fresh ones inside a single
Repo.transaction/1. Returns the raw formatted codes for display (shown
once, never retrievable again).
Parameters
repo- The Ecto repo modulebackup_code_schema- The generated backup code Ecto schema moduleuser_id- The user's IDcount- Number of codes to generate
@spec remaining_count(module(), module(), term()) :: non_neg_integer()
Returns the count of unused backup codes for a user.