CARLOS rastrillo docs

๐Ÿค– totp

amadan.net/rastrillo/rastrillo/totp

An authenticator-app second factor: RFC 6238 time-based one-time passwords, the six digits every authenticator app produces, on the same two seams the passkey package uses. At sign-in it completes secondfactor's pending half-session; at step-up it makes a valid-but-stale session fresh again.

It is the factor for the person without a passkey-capable device, and the one whose proof is a plain form field: every endpoint here is a form POST, so a confirm page built on it works with JavaScript off โ€” which is also the moment a passkey ceremony cannot run.

Second factors and authenticator apps is the guide.

The trust boundary

Nobody is signed in from nothing: Handlers.SignIn demands a live half-session, Handlers.StepUp demands a session.

New, Config and Schema

func New(cfg Config) (*Handlers, error)

Config requires DB, Sessions, Key and Issuer. Gate is the shared second-factor seam โ€” leaving it nil keeps enrolment and step-up working and makes sign-in refuse. Issuer names the app in the authenticator's list, so make it the name people know the app by: they will see it beside the code for years. StepUpFailedPath defaults to /signin?reauth=1&totp=failed, and Logger is optional.

Merge Schema into your boot set. It is one table: a sealed secret per subject, confirmed or not, and the last step it verified at.

Handlers is one per process โ€” the step-up limiter's state lives on the value. Register it with the gate, g.Add(tp), so Handlers.Enrolled answers the secondfactor.Factor contract.

Secrets at rest

A TOTP secret is symmetric: whoever reads it can mint codes. It is sealed under Config.Key (AES-256-GCM, crypto.SealSym) before it touches the database, so a copied database file is not a copied factor. Derive the key from the instance key with crypto.Derive โ€” never store it beside the data it seals. Losing it loses every enrolment.

Enrolment

Call What it does
Handlers.Begin Mints a secret and returns what to show. An unconfirmed secret from an abandoned attempt is replaced; a confirmed one is ErrEnrolled.
Handlers.Pending The enrolment Begin started and nobody has confirmed yet, so a set-up page can be a GET that shows the same QR after a refresh.
Handlers.Confirm Checks one code against the unconfirmed secret and, if it verifies, makes the factor live.
Handlers.Disable Removes the authenticator, confirmed or not.

Enrolment is what a set-up page shows: Key to type in by hand, URI for the otpauth: link, and QR, the same URI as an inline SVG โ€” mark it template.HTML. The factor is live only once Confirm has seen a code from it, which is what proves the app really scanned the QR.

Begin and Disable belong on pages behind sessions.RequireFresh, like every change to how an account is entered. ErrEnrolled says to disable first rather than silently replacing a live secret, which would let a stale session swap the factor out from under its owner.

The endpoints

Mount them behind csrf.Protect like every other mutating route:

Route Handler Fields
POST /totp/signin Handlers.SignIn code
POST /totp/stepup Handlers.StepUp code, return_to

SignIn verifies the code against the pending half-session's subject and completes it through the gate, so the real session carries the first factor's method plus +totp. A miss is a secondfactor.Gate.Strike: the half-session survives until the fifth, redirecting to the confirm page with ?totp=failed, then ?totp=exhausted once the budget is spent. Which digits were wrong is never said.

StepUp verifies a code for the caller โ€” whose session may be stale, which is the point โ€” and rotates the session fresh with Method ("totp") and AuthTime now, which is what satisfies sessions.RequireFresh again. Success redirects to the form's same-site return_to, else /; a miss to Config.StepUpFailedPath with return_to carried along.

Guessing

Six digits is a million-code space, and a code stays valid for the current 30-second step plus one either side, so a guess is worth three in a million. At sign-in the budget is the half-session's โ€” five misses and the person starts again from the first factor. At step-up and enrolment it is a per-subject budget of ten misses in fifteen minutes, in memory, the password plugin's shape. A verified code's step is remembered and never accepted twice, so a code read over a shoulder is spent the moment its owner uses it.

func Code(key string, t time.Time) (string, error)

Code is the code an authenticator holding key โ€” the base32 string Enrolment shows โ€” produces at t. It is there for a test that wants to play the phone, and it is also the only way a code is computed here: verification runs the same function across the window.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.