class

KemalIdentity::MFA::Service

Inherits Reference < Object

Enrolling, proving and removing second factors.

What makes TOTP safe is here, not in TOTP

TOTP computes and compares six digits. Six digits is one of a million, a code is valid for its whole period plus the drift either side, and neither of those facts is a problem until you notice that nothing so far stops an attacker submitting codes in a loop. Three things do, and all three live in this class:

  1. A rate limit on every attempt, consumed before the code is checked. Without it, a million guesses against a 90-second window is a few minutes of traffic.
  2. Single use. A counter that verified once is spent, atomically, so a code read over somebody's shoulder is worthless by the time it is typed a second time.
  3. Confirmation before the factor counts. A secret that was generated but never proved is a secret nobody may actually hold, and treating it as a factor is how a person locks themselves out of their own account.

Freshness is the caller's to enforce

docs/06-roadmap.md requires fresh authentication for disabling MFA, replacing a factor and using a recovery code. That check belongs at the route — env.auth.require_fresh! — and not here, because this class takes an account id and has no request to inspect. The methods that need it say so, and examples/ shows the guard.

Constants

DEFAULT_QUOTA_PREFIX = "mfa"

Attempts allowed before the limiter is consulted for a verdict, per account.

DEFAULT_RECOVERY_CODES = 10

How many recovery codes to issue at once.

Ten is the usual number, and the trade-off is real in both directions: too few and the person runs out at the worst moment, too many and the printed list is a long-lived bypass sitting in somebody's downloads folder.

RECOVERY_CODE_BYTES = RandomSource::TOKEN_BYTES

Bytes of entropy per recovery code.

The full RandomSource::TOKEN_BYTES, which is the floor docs/02-security-model.md sets for any secret handed to a browser. A recovery code skips the second factor outright, so it is the last thing that should be granted an exception to that rule — and the usual argument for a shorter one, that people type these by hand, is answered by printing them in groups: #redeem_recovery_code strips the spacing back out.

Constructors

new(factors : Repository, secret_box : SecretBox, clock : Clock, random : RandomSource, issuer : String, rate_limiter : RateLimiter = NullRateLimiter.new, sessions : Sessions::Service | Nil = nil, drift : Int32 = 1, digits : Int32 = TOTP::DEFAULT_DIGITS, period : Time::Span = TOTP::DEFAULT_PERIOD, algorithm : TOTP::Algorithm = TOTP::Algorithm::SHA1, recovery_code_count : Int32 = DEFAULT_RECOVERY_CODES, quota_prefix : String = DEFAULT_QUOTA_PREFIX, recovery_rate_limiter : RateLimiter | Nil = nil, max_consecutive_failures : Int32 | Nil = nil)
Source

Instance methods

confirm(factor_id : String, code : String, account_id : String | Nil = nil, ip : String | Nil = nil) : Confirmed | Nil

Finishes enrolment by checking a code from the new factor.

Returns nil when the code does not verify, and Confirmed when it does — carrying recovery codes if this is what turned MFA on for the account. Rate limited like any other code submission: an unconfirmed factor is still a guessable secret. account_id, when given, is checked against the factor's own: a route that takes the factor id from a client should pass it, for the reason #remove gives. Confirming somebody else's pending enrolment also needs a code from their secret, so this is the cheaper of the two guards rather than the load-bearing one.

Source
disable(account_id : String) : Int32

Turns MFA off for an account: every factor, and every recovery code with them.

This lowers the account's security, so it is exactly the operation docs/06-roadmap.md requires fresh authentication for. Audited at warning level — an attacker who has hijacked a session will try this before anything else.

Source
drift
Source
enrol(account : Accounts::Account, label : String) : PendingEnrolment

Generates a secret and stores it unconfirmed, returning what to show the person.

Nothing is protected yet. The factor does not count until #confirm proves that whoever asked for it can actually produce a code from it.

label is what the person will see in their authenticator app next to the six digits, so the login is the usual choice.

Source
enrolled?(account_id : String) : Bool

Whether this account has at least one factor that has been proved and still works.

Unconfirmed factors deliberately do not count: a half-finished enrolment must not make the login screen start demanding a code nobody can produce. Nor do disabled ones, for the same reason from the other end — a factor switched off for consecutive failures cannot produce an accepted code either, and an account left with only those must fall back to recovery rather than being asked for a code that can never work.

Source
factors(account_id : String) : Array(Factor)

Every factor on the account, for a management screen. Unconfirmed ones included, since that screen has to show what is half-finished.

Source
max_consecutive_failures

How many consecutive wrong codes disable a factor, or nil for no bound.

nil is the shipped default and does not meet NIST SP 800-63B, which says the verifier SHALL limit consecutive failed attempts against one authenticator "to no more than 100 by disabling that authenticator". A rate limiter cannot stand in for it: a window resets and grants the same budget again forever. Measured with a five-minute window of twelve — the configuration a real consumer had — that is 103,680 attempts in thirty days, which against six digits with drift: 1 is about a 27% chance of being guessed. blueprints/0025 (MFA-04) has the measurement and blueprints/0029 the decision.

It defaults to nil rather than to 100 because switching it on can disable a factor, and a deployment that has been running without it may have factors sitting on hundreds of accumulated failures from ordinary typos over years — turning the bound on would lock those people out at once. Set it deliberately, after deciding what your support path for a disabled factor is. New deployments should set it.

Source
redeem_recovery_code(account_id : String, code : String, except_session_id : String | Nil = nil, ip : String | Nil = nil) : VerificationResult

Spends a recovery code.

The way back in when the phone is gone, and therefore a full bypass of the second factor: rate limited like #verify, consumed atomically so two requests cannot both spend it, and audited at warning level. A recovery code being used is either somebody's worst day or an attacker's best one, and it is worth an alert either way.

docs/06-roadmap.md requires fresh authentication for this; enforce it at the route.

It signs the account's other sessions out

docs/02-security-model.md lists MFA recovery among the events that revoke all of an account's sessions, and the reason is the situation that produces one: somebody is redeeming a code because they have lost the device, and "lost" and "taken" look identical from here. Anything already signed in elsewhere is exactly what needs ending.

except_session_id spares the session doing the redeeming, which is normally the one half-way through a login. Pass it, or the person is signed out by their own recovery. Requires a Sessions::Service; with none configured the codes still work and nothing is revoked, which is a weaker arrangement and one an application has to choose deliberately.

Its quota is its own

A recovery code is spent by somebody whose second factor has just failed them, so sharing a bucket with #verify meant the credential for that exact situation was unavailable in that exact situation — measured in blueprints/0025 (MFA-04). The bucket is separate, and recovery_rate_limiter: can give it a different limit as well as a different key. Safe because a recovery code is a 43-character CSPRNG string rather than six digits: throttling it protects the endpoint, not the secret.

Source
regenerate_recovery_codes(account_id : String) : Array(Secret)

Issues a fresh set of recovery codes, voiding whatever was there.

Voiding is the point: this is what somebody calls when they think the old list leaked. Requires fresh authentication at the route.

Source
remove(factor_id : String, allow_last : Bool = true) : Bool

Removes one factor by id alone, so it is an administrative call.

A route that lets a client name the factor to remove — DELETE /mfa/factors/:id — must use the account-scoped form below, or it will happily remove somebody else's second factor. A factor id is not secret material: it appears in mfa.verified and mfa.factor_removed audit lines and in any management listing, which is the same reasoning ApiTokens::Service#revoke carries.

Recovery codes are left alone: removing one of two devices is not "MFA is off", and voiding the codes would be a surprise in the direction of locking somebody out.

Requires fresh authentication at the route.

Source
remove(factor_id : String, account_id : String, allow_last : Bool = false) : Bool

Removes one factor only if it belongs to account_id, which is what a "remove this device" button in a user's own settings needs.

Answers false for a factor that belongs to somebody else and for one that does not exist — the same answer, so a caller cannot use the difference to discover whether an id is real.

allow_last defaults to false here and to true in the administrative form, and the asymmetry is the point: removing a person's only remaining second factor turns MFA off for their account, which is a different intent from unregistering a device they still have two of. A settings screen that means the first says so, and then gets the recovery codes voided with it.

Source
unused_recovery_codes(account_id : String) : Int32
Source
verify(account_id : String, code : String, ip : String | Nil = nil) : VerificationResult

Checks a code against every confirmed factor on the account.

The attempt is counted before any code is checked, for the reason RateLimiter gives: counting afterwards means a wrong guess that times out is a free guess.

Every confirmed factor is tried, because a person with two devices should be able to use either, and only the factor that actually matched has its counter spent.

Source