Sigra.MFA (Sigra v1.20.0)

Copy Markdown View Source

Core MFA orchestrator module.

All security-critical MFA operations live here. The generated MyApp.Auth context delegates to these functions for TOTP enrollment, verification, backup code management, and MFA lifecycle.

Usage

# Enrollment
{:ok, enrollment} = Sigra.MFA.enroll(config, account: "user@example.com")

# Verification
{:ok, :verified} = Sigra.MFA.verify(config, user, "123456")

# Status check
Sigra.MFA.enabled?(config, user)

Security Properties

  • TOTP secrets generated via NimbleTOTP (RFC 6238 compliant)
  • Drift window: configurable +/- steps (default +/-1 = 30s each side)
  • Replay prevention via last_verified_step tracking (D-41)
  • Backup codes: SHA-256 hashed, atomic consumption (D-13, D-16)
  • Lockout after configurable failed attempts (D-19)
  • TOTP secrets encrypted at rest via cloak_ecto (D-09)

Summary

Functions

Record an mfa.backup_codes_regenerate audit row via Ecto.Multi + Sigra.Audit.log_multi_safe/3 inside Repo.transaction/1 when :audit_schema is configured (same telemetry semantics as legacy log_safe/3 on failure).

Record an mfa.trust_browser audit row via Ecto.Multi + Sigra.Audit.log_multi_safe/3 inside Repo.transaction/1 when :audit_schema is configured. Intended for Sigra.MFA.Trust when a browser is marked trusted.

Confirms TOTP enrollment by verifying a code against an unconfirmed secret.

Disables MFA for a user after verifying a TOTP or backup code.

Force-disables MFA for a user without code verification (admin action).

Checks if a user has MFA enabled.

Generates TOTP enrollment data.

Regenerates backup codes after verifying a TOTP code ({:totp, code}).

Returns MFA status for a user.

Verifies a TOTP code for an authenticated user.

Verifies a backup code for an authenticated user.

Verifies a TOTP code against a secret with drift and replay prevention.

Functions

audit_backup_codes_regenerate(config, user, count)

@spec audit_backup_codes_regenerate(Sigra.Config.t(), struct(), non_neg_integer()) ::
  :ok

Record an mfa.backup_codes_regenerate audit row via Ecto.Multi + Sigra.Audit.log_multi_safe/3 inside Repo.transaction/1 when :audit_schema is configured (same telemetry semantics as legacy log_safe/3 on failure).

This is not the authoritative audit path when audit is enabled for library-driven rotation — use regenerate_backup_codes/4, which appends the same action to the rotation Ecto.Multi. This function remains for ad-hoc or legacy call sites.

audit_trust_browser(config, user)

@spec audit_trust_browser(
  Sigra.Config.t(),
  struct()
) :: :ok

Record an mfa.trust_browser audit row via Ecto.Multi + Sigra.Audit.log_multi_safe/3 inside Repo.transaction/1 when :audit_schema is configured. Intended for Sigra.MFA.Trust when a browser is marked trusted.

confirm_enrollment(config, user, raw_secret, code, opts)

(since 0.6.0)
@spec confirm_enrollment(Sigra.Config.t(), struct(), binary(), String.t(), keyword()) ::
  {:ok, map()} | {:error, :invalid_code}

Confirms TOTP enrollment by verifying a code against an unconfirmed secret.

If valid, creates the MFA credential in the database with the encrypted secret, generates backup codes, and returns both.

Parameters

  • config - Sigra config
  • user - The user struct (must have :id)
  • raw_secret - The raw TOTP secret binary (from enrollment, held in session)
  • code - The 6-digit TOTP code to verify
  • opts - Options including :mfa_credential_schema and :backup_code_schema

Returns

  • {:ok, %{credential: credential, backup_codes: codes}} when the code is valid and the enrollment transaction succeeds.
  • {:error, :invalid_code} when the TOTP check fails. This tuple is returned regardless of audit subsystem outcome: with :audit_schema configured, Sigra may attempt a durable mfa.enroll.failure row in an audit-only transaction (Repo.transaction/1 + Multi + log_multi_safe); audit persistence failure does not change the return value. Operators should monitor [:sigra, :audit, :log_safe_error] for forensic signals.

disable(config, user, code, opts \\ [])

(since 0.6.0)
@spec disable(Sigra.Config.t(), struct(), String.t(), keyword()) ::
  {:ok, :disabled} | {:error, atom()}

Disables MFA for a user after verifying a TOTP or backup code.

Requires current code verification before deletion (D-59). Deletes MFA credential, all backup codes, and increments trust_epoch (D-60).

Options

  • :mfa_credential_schema - The generated MFA credential Ecto schema module
  • :backup_code_schema - The generated backup code Ecto schema module

When :audit_schema is set, a failed cleanup transaction returns {:error, :mfa_audit_failed} (audit insert changeset) or {:error, :mfa_disable_failed} (other Ecto.Multi steps). Database constraint violations on the audit row may still raise Ecto.ConstraintError (same as other audited MFA transactions) after the repo rolls back.

disable!(config, user, opts \\ [])

(since 0.6.0)
@spec disable!(Sigra.Config.t(), struct(), keyword()) :: {:ok, :disabled}

Force-disables MFA for a user without code verification (admin action).

Same cleanup as disable/4 but skips code verification (D-65).

Raises RuntimeError when the cleanup Ecto.Multi returns an error tuple (after rollback). Database constraint violations on the audit insert may instead raise Ecto.ConstraintError, matching other audited MFA paths.

Options

  • :mfa_credential_schema - The generated MFA credential Ecto schema module
  • :backup_code_schema - The generated backup code Ecto schema module

enabled?(config, user)

(since 0.6.0)
@spec enabled?(
  Sigra.Config.t(),
  struct()
) :: boolean()

Checks if a user has MFA enabled.

Returns true if the user has an MFA credential with enabled_at != nil.

Options

  • :mfa_credential_schema - The generated MFA credential Ecto schema module

enroll(config, opts \\ [])

(since 0.6.0)
@spec enroll(
  Sigra.Config.t(),
  keyword()
) :: {:ok, map()}

Generates TOTP enrollment data.

Creates a new TOTP secret and returns the base32-encoded secret, otpauth URI, optional SVG QR code, and raw binary secret.

The raw secret should be held in the encrypted session until the user confirms enrollment with a valid code (D-03).

Options

  • :account - The account name for the otpauth URI (e.g., user email)

Returns

{:ok, %{
  secret: "BASE32ENCODED...",
  otpauth_uri: "otpauth://totp/...",
  svg: "<svg>...</svg>" | nil,
  raw_secret: <<binary>>
}}

regenerate_backup_codes(config, user, arg, opts)

(since 0.6.0)
@spec regenerate_backup_codes(
  Sigra.Config.t(),
  struct(),
  {:totp, String.t()},
  keyword()
) ::
  {:ok, %{backup_codes: [String.t()]}}
  | {:error, :invalid_code, non_neg_integer()}
  | {:error, :lockout, non_neg_integer()}
  | {:error, :not_enrolled}

Regenerates backup codes after verifying a TOTP code ({:totp, code}).

Backup codes cannot authorize rotation — only a valid TOTP proof is accepted.

Replacement runs in a single Repo.transaction/1 (delete_all + insert_all

  • optional audit). When :audit_schema is configured, an mfa.backup_codes_regenerate row is written via Sigra.Audit.log_multi_safe/3 on the same Ecto.Multi as the replace (atomic with persistence).

On success, lockout counters are cleared and last_verified_step / last_used_at are updated like verify/4, but this path intentionally does not emit mfa.verify.success — rotation is covered by mfa.backup_codes_regenerate when audit is enabled.

Options

  • :mfa_credential_schema (required)
  • :backup_code_schema (required)

Returns

Same error shapes as verify/4 for :not_enrolled, :lockout, and :invalid_code (with remaining attempts).

status(config, user, opts \\ [])

(since 0.6.0)
@spec status(Sigra.Config.t(), struct(), keyword()) :: map()

Returns MFA status for a user.

Options

  • :mfa_credential_schema - The generated MFA credential Ecto schema module (via config or opts)
  • :backup_code_schema - The generated backup code Ecto schema module (via config or opts)

Returns

%{enabled: boolean, type: "totp" | nil, backup_codes_remaining: integer}

verify(config, user, code, opts \\ [])

(since 0.6.0)
@spec verify(Sigra.Config.t(), struct(), String.t(), keyword()) ::
  {:ok, :verified}
  | {:error, :invalid_code, non_neg_integer()}
  | {:error, :lockout, non_neg_integer()}
  | {:error, :not_enrolled}

Verifies a TOTP code for an authenticated user.

Fetches the MFA credential, checks lockout, verifies the code with drift and replay prevention. On success, resets attempt counter and updates last_verified_step.

Options

  • :mfa_credential_schema - The generated MFA credential Ecto schema module

Returns

  • {:ok, :verified} on success
  • {:error, :invalid_code, remaining_attempts} on wrong code
  • {:error, :lockout, remaining_seconds} when locked out
  • {:error, :not_enrolled} when user has no MFA credential

verify_backup(config, user, code, opts \\ [])

(since 0.6.0)
@spec verify_backup(Sigra.Config.t(), struct(), String.t(), keyword()) ::
  {:ok, :consumed, non_neg_integer()}
  | {:error, :invalid_backup_code, non_neg_integer()}
  | {:error, :lockout, non_neg_integer()}
  | {:error, :not_enrolled}

Verifies a backup code for an authenticated user.

Fetches MFA credential, checks lockout (shared counter per D-19), calls BackupCodes.consume/4. On success, resets attempt counter.

Options

  • :mfa_credential_schema - The generated MFA credential Ecto schema module
  • :backup_code_schema - The generated backup code Ecto schema module

verify_totp(secret, code, last_verified_step, drift_steps)

(since 0.6.0)
@spec verify_totp(binary(), String.t(), integer(), non_neg_integer()) ::
  {:ok, integer()} | {:error, :replay | :invalid_code}

Verifies a TOTP code against a secret with drift and replay prevention.

This is exposed as a public function for testing purposes but is intended as an internal helper. Checks the code against the current time step and +/- drift steps. Rejects codes from steps at or below last_verified_step to prevent replay attacks (D-41).

Returns

  • {:ok, step} if valid (step is the TOTP time step that matched)
  • {:error, :replay} if code matches but step <= last_verified_step
  • {:error, :invalid_code} if code doesn't match any step