Documentation
¶
Overview ¶
Package twofactor declares what a second factor is: how an authenticator application is enrolled, how the code it shows is checked, and what a recovery code has to be.
The directory is named 2fa and the package is named twofactor because a Go identifier cannot begin with a digit, so "package 2fa" does not compile. The import path is what a reader goes looking for; the package name is what the compiler will accept.
It stores nothing, and that is the shape of it rather than an omission. There is no table here, no migration, no account, and no assumption that a table of people exists: the secret and the recovery codes belong to whatever owns the schema, and this package says what that owner has to guarantee about them. What is here is computation -- Provisioning.URI and GenerateRecoveryCodes -- and the two interfaces the computation needs somebody else to implement, ReplayGuard and RecoveryStore.
A second factor proves possession of a device. It is not a code delivered to an address, which proves only that the address is reachable, and nothing in this package sends anything anywhere.
The algorithm itself is in the otp package, which this one uses and which does not know this one exists.
Index ¶
Constants ¶
const ( // RecoveryCodeLength is how many characters a recovery code has. // // Ten, drawn from a 32-character alphabet, is fifty bits. It has to survive // being guessed by whoever already has the password, and it has to survive // being copied onto paper by hand. RecoveryCodeLength = 10 // DefaultRecoveryCodes is how many codes an enrolment usually issues. Enough // that losing a couple is not losing the account, few enough to write down // in one sitting. DefaultRecoveryCodes = 8 )
Variables ¶
var ( // ErrInvalidCode is a code that does not authenticate. Every reason it does // not unwraps to this, because the person at the keyboard is told the same // thing either way and telling them more is telling whoever is guessing // more. ErrInvalidCode = errors.New("twofactor: the code is not valid") // ErrReplayed is a code that was right and has already been spent. It // unwraps to [ErrInvalidCode], so a caller that does not care about the // difference does not have to check for both -- and one that does can log // it, because a replay is somebody else's attempt and not a typo. ErrReplayed = fmt.Errorf("%w: it has already been used", ErrInvalidCode) // ErrNotConfigured is a check that cannot be made: no replay guard, or no // subject to remember the code against. // // It is not a variety of [ErrInvalidCode], and that is the point. A // misconfigured check must not read as a wrong code, because a wrong code is // a thing an application shrugs at and tries again. ErrNotConfigured = errors.New("twofactor: the check cannot be made") )
var ErrProvisioning = errors.New("twofactor: the enrolment cannot be provisioned")
ErrProvisioning is an enrolment that cannot be handed to an authenticator application. Every reason it is refused unwraps to this one, because the answer is the same in all of them: fix the enrolment, do not show a URI.
var ErrRecoveryCount = errors.New("twofactor: that number of recovery codes cannot be issued")
ErrRecoveryCount is a number of codes that cannot be issued.
Functions ¶
func GenerateRecoveryCodes ¶
GenerateRecoveryCodes returns n fresh recovery codes in canonical form.
A recovery code is the only way back in when the phone is gone, and it is the only fallback there is: nothing here mails a code to an address, because an address is not a second factor. Which makes what the caller does with these the whole of their security -- they are shown once, they are stored hashed, and each is spent once.
The codes come back in the form NormalizeCode produces, so the string that is hashed at issue is the string a form will produce later. A caller that wants to print them in groups may add separators for display; it must not store what it printed.
func NormalizeCode ¶
NormalizeCode strips what a person adds around a code and leaves what the comparison needs: spaces and hyphens go, and letters are folded up.
Both codes a person types are normalised the same way and for the same reason. An authenticator shows six digits in two groups and people copy the gap; a recovery code is read off paper and typed with whatever grouping was printed. Neither should be refused for how it was spaced.
It is the canonical form. A RecoveryStore hashes and compares what this returns, so that what was stored at issue and what arrives from a form are the same string.
Types ¶
type Authenticator ¶
type Authenticator struct {
// TOTP is the configuration the enrolment was provisioned under. The zero
// value means otp.Default, and it has to be the same configuration the
// provisioning URI carried or the two sides compute different codes.
TOTP otp.TOTP
// Guard is where a spent step is recorded. It is required: with no guard
// there is no way to refuse a second use, so [Authenticator.Verify] refuses
// everything instead.
Guard ReplayGuard
// Now is the clock. A nil Now means time.Now; a test sets it.
Now func() time.Time
}
Authenticator checks the codes an authenticator application produces.
It holds no secret and no account. The secret is handed to Authenticator.Verify by whoever stored it, and the memory of which codes have been spent is the ReplayGuard's.
func (Authenticator) Verify ¶
func (a Authenticator) Verify(ctx context.Context, subject string, secret []byte, code string) error
Verify reports whether code authenticates subject against secret.
It returns nil only when the code was one the secret produces in the accepted window and the step it belongs to had not been spent. The order is deliberate: the code is checked first and the step spent afterwards, so that somebody typing wrong codes cannot burn the steps of the person whose account it is.
Every failure is refusal. A guard that cannot answer is refused, an empty subject is refused, and a configuration that cannot produce codes is refused; there is no argument of this function that turns the check off.
type Provisioning ¶
type Provisioning struct {
// Issuer names the application, and is what the person sees at the top of
// the entry in their authenticator. It cannot contain a colon.
Issuer string
// Account names the person within the application: usually the address they
// sign in with. It cannot contain a colon.
//
// It is shown under the issuer, and it is how somebody with three accounts
// on the same service tells them apart -- so an identifier only the database
// understands is a bad choice here.
Account string
// Secret is the shared secret, as raw bytes. otp.NewSecret produces one.
//
// This is key material. It goes into the URI, it reaches the phone, and it
// must not reach a log.
Secret []byte
// TOTP is the code length, time step and tolerance. The zero value means
// otp.Default, which is what an application assumes when it is not told.
TOTP otp.TOTP
}
Provisioning is everything an authenticator application needs in order to start producing codes for one account.
It is the request, not the record. Nothing here is stored by this package, and the same struct produces the QR code and the key a person types in when the camera will not focus -- Provisioning.URI and otp.EncodeSecret over the same Provisioning.Secret.
func (Provisioning) URI ¶
func (p Provisioning) URI() (string, error)
URI returns the otpauth:// provisioning URI, which is what a QR code for this enrolment encodes.
The label carries the issuer and the account separated by a colon, and the issuer is repeated as a parameter -- both, because an older application reads only the label and a newer one reads only the parameter, and an enrolment that names the service in one place shows up unnamed in half of them.
The code length and period are written out even though they are the defaults. An application that assumes different ones would otherwise produce codes this server never accepts, and the person would have no way to tell why.
type RecoveryStore ¶
type RecoveryStore interface {
// Consume spends one of subject's recovery codes and reports whether code
// was one of them and still unspent.
//
// False is the ordinary answer for a wrong code and carries no error. An
// error means the store could not decide, and the caller refuses.
Consume(ctx context.Context, subject, code string) (bool, error)
}
RecoveryStore is where recovery codes live between being issued and being spent.
It is an interface for the reason ReplayGuard is: the codes belong to whatever owns the account, and a store shipped here would be a second opinion about what an account is. What this package can state is what the store has to guarantee, and the guarantees are the security of the mechanism rather than implementation advice:
- The codes are stored hashed, with a password hash and not a bare digest. They are the fallback for the second factor, so a leaked table of them is a leaked table of second factors.
- The comparison is in constant time, which a password hash's own verifier already gives.
- Spending a code is atomic, and a code spends exactly once. Two requests arriving together with the same code is the case that decides whether "once" is true.
- The lookup is scoped to the subject. One person's code must never open another person's account, and a store that searches by code alone makes that happen the first time two codes collide.
- The code is compared as NormalizeCode returns it, at issue and at use.
- Storage that cannot be reached refuses the attempt. There is no path on which the store being down lets somebody in.
type ReplayGuard ¶
type ReplayGuard interface {
// Spend records step as used by subject and reports whether it was still
// free -- true the first time, false every time after.
//
// It must be atomic. A read followed by a write at the call site is exactly
// the race this interface exists to close, and two requests arriving
// together with the same stolen code is the case that matters.
//
// It must fail rather than guess. An implementation that cannot reach its
// storage returns an error, and the verification is refused; there is no
// path on which storage being down lets a code through, because that path
// is the one an attacker would create on purpose.
//
// The subject is opaque here: it is whatever identifies the account to the
// caller. It must not be empty, and two accounts must never share one.
Spend(ctx context.Context, subject string, step uint64) (bool, error)
}
ReplayGuard remembers which time steps a subject has already spent.
It exists because a correct code stays correct for the whole of its time step: without this, a code read over somebody's shoulder, or captured by whatever put the phishing page in front of them, works for as long as it is still on the screen.
It is an interface and not an implementation because the memory belongs to whoever owns the account. This package stores nothing, and a store it shipped would be a second place where the shape of an account is decided.