Sigra.MFA.BackupCodes (Sigra v1.20.0)

Copy Markdown View Source

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

append_replace_steps(multi, backup_code_schema, user_id, count, now \\ DateTime.utc_now())

(since 0.6.0)
@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.

consume(repo, backup_code_schema, user_id, submitted_code)

(since 0.6.0)
@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 module
  • backup_code_schema - The generated backup code Ecto schema module
  • user_id - The user's ID
  • submitted_code - The raw code submitted by the user

generate(count \\ 8)

(since 0.6.0)
@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

hash(submitted_code)

(since 0.6.0)
@spec hash(String.t()) :: String.t()

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")

regenerate(repo, backup_code_schema, user_id, count)

(since 0.6.0)
@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 module
  • backup_code_schema - The generated backup code Ecto schema module
  • user_id - The user's ID
  • count - Number of codes to generate

remaining_count(repo, backup_code_schema, user_id)

(since 0.6.0)
@spec remaining_count(module(), module(), term()) :: non_neg_integer()

Returns the count of unused backup codes for a user.