class

KemalIdentity::SQLite::MfaRepository

Inherits KemalIdentity::MFA::Repository < Reference < Object

MFA::Repository over auth_mfa_factors and auth_mfa_recovery_codes.

The two single-use operations are each one statement, for the reason blueprints/0011-action-token-atomicity.md gives: a read followed by a write passes every spec written against one fiber and fails against two, and here failing means a replayed TOTP code or a recovery code spent twice.

Constants

FACTOR_COLUMNS = "id, account_id, kind, label, sealed_secret, digits, period_seconds, algorithm,\ncreated_at, confirmed_at, last_used_counter, consecutive_failures, last_failure_at,\ndisabled_at"

Constructors

Instance methods

clear_failures(id : String) : Bool

Zeroes the consecutive failure count. What a successful verification calls.

Returns false if the factor does not exist. Idempotent: a factor already at zero is not an error, since every success calls this and most successes follow a success.

Source
confirm_factor(id : String, counter : Int64, at : Time) : Bool

AND confirmed_at IS NULL reports whether anything changed and stops a second confirmation overwriting the first timestamp, which is the one an audit trail wants.

Source
consume_counter(id : String, counter : Int64, at : Time) : Bool

The replay defence, as one statement. last_used_counter IS NULL OR ... < ? is what makes "check that this counter is new" and "record that it is now used" the same operation, so two requests carrying the same intercepted code cannot both succeed.

Source
consume_recovery_code(account_id : String, digest : Bytes, at : Time) : Bool

Single use, as one statement, for the same reason as #consume_counter.

Source
create_factor(factor : MFA::Factor) : Nil

Stores a newly enrolled, unconfirmed factor.

Raises InfrastructureError if the id already exists, rather than overwriting: a collision means the id source is broken, and silently replacing an enrolled factor is how somebody loses access to their account.

Source
delete_factor(id : String) : Bool

Removes one factor. Returns false if it was not there.

Source
delete_factors_for_account(account_id : String) : Int32

Removes every factor for an account, returning how many. What "disable MFA" calls.

Source
disable_factor(id : String, at : Time) : Bool

AND disabled_at IS NULL reports whether anything changed and keeps the first timestamp, which is the one an audit trail wants.

Source
factors_for_account(account_id : String) : Array(MFA::Factor)

Every factor for an account, confirmed or not, oldest first. Unconfirmed ones are included because the enrolment screen has to show what is half-finished.

Source
find_factor(id : String) : MFA::Factor | Nil
Source
record_failure(id : String, at : Time) : Int32 | Nil

One statement, and the count comes back from the same one: two parallel wrong guesses must count as two, and a read followed by a write loses one of them — in the direction that favours whoever is guessing.

SQLite has had RETURNING since 3.35 (2021). query_one? rather than query_one answers nil for a factor that is not there instead of raising.

Source
replace_recovery_codes(account_id : String, codes : Array(MFA::RecoveryCode)) : Nil

One transaction, because the two halves are a security hole apart: an account left briefly with no codes cannot recover, and one left briefly with both sets has old codes that were supposed to be void.

Source
unused_recovery_codes(account_id : String) : Int32

How many unused recovery codes an account has left, so a screen can say "2 remaining" before somebody discovers it is zero at the worst moment.

Source