Documentation
¶
Overview ¶
Package totp implements TOTP (RFC 6238) with zero external dependencies.
It supports generating and validating time-based one-time passwords using HMAC-SHA1, HMAC-SHA256, or HMAC-SHA512. It also generates otpauth:// URIs for use with authenticator apps.
Index ¶
- Variables
- type AESEncryptor
- type Algorithm
- type Config
- type Credential
- type Encryptor
- type Limiter
- type Option
- type Service
- func (s *Service) ConfirmEnrollment(ctx context.Context, userID, code string) error
- func (s *Service) Enroll(ctx context.Context, userID, accountName string) (secret, uri string, err error)
- func (s *Service) Generate(secret string, t time.Time) (string, error)
- func (s *Service) ReplaceEnrollment(ctx context.Context, userID, accountName string) (secret, uri string, err error)
- func (s *Service) Unenroll(ctx context.Context, userID string) error
- func (s *Service) Validate(ctx context.Context, userID, code string) error
- type Store
Constants ¶
This section is empty.
Variables ¶
var ( ErrTOTPInvalid = errors.New("totp: invalid code") ErrTOTPNotEnrolled = errors.New("totp: not enrolled") ErrTOTPNotVerified = errors.New("totp: enrollment not verified") ErrTOTPReplayed = errors.New("totp: code already used") ErrTOTPRateLimited = errors.New("totp: rate limited") // ErrTOTPAlreadyEnrolled is returned by Enroll when the user already // has an active (verified) TOTP credential. Enroll refuses outright // rather than silently overwriting a working second factor — see // Enroll's GoDoc and ReplaceEnrollment for the explicit path to // supersede one on purpose. ErrTOTPAlreadyEnrolled = errors.New("totp: already enrolled") )
Functions ¶
This section is empty.
Types ¶
type AESEncryptor ¶
type AESEncryptor struct {
// contains filtered or unexported fields
}
AESEncryptor implements Encryptor with AES-256-GCM: a random 96-bit nonce on every Encrypt call (crypto/rand — never reused, so encrypting the same secret twice yields two unrelated ciphertexts), and a per-key fingerprint prefixed onto the output so Decrypt can tell which of possibly several configured keys produced a given ciphertext, without any external key-ID bookkeeping.
func NewAESEncryptor ¶
func NewAESEncryptor(key []byte, rotated ...[]byte) (*AESEncryptor, error)
NewAESEncryptor builds an AES-256-GCM Encryptor. key must be exactly 32 bytes (AES-256) and is the CURRENT key: every future Encrypt call uses it. rotated, if given, are additional keys Decrypt will also accept — typically previous values of key — so ciphertext written under them keeps decrypting after a rotation; every key in rotated must also be exactly 32 bytes.
Rotating a key means: construct a new AESEncryptor with the new key as key and the old key(s) as rotated, and swap it in via WithEncryptor. Already-stored ciphertext keeps decrypting (its embedded fingerprint still names the old key, which is still configured); every new Encrypt call moves to the new key. There is no in-place "re-encrypt everything" step — like Argon2's rehash-on-login (T504), a secret only actually moves onto the new key the next time Service writes it (Validate's counter bump, or a fresh Enroll/ReplaceEnrollment), not immediately on rotation.
A key's fingerprint — the key-ID prefix embedded in its ciphertext — is derived from the key itself (HMAC-SHA256 keyed by the key, over a fixed label, truncated to 8 bytes), never assigned by the caller or inferred from argument position. That is deliberate: passing the same keys back in a different order, or promoting an old key to current, can never make an existing ciphertext's fingerprint resolve to the wrong key.
type Config ¶
type Config struct {
Issuer string // e.g. "MyApp"
Algorithm Algorithm // default: SHA1
Digits int // default: 6
Period uint64 // seconds, default: 30
Skew uint // number of periods to check before/after current (default: 1)
SecretSize int // bytes of entropy for new secrets (default: 20)
Limiter Limiter // rate limiter consulted before validating a code (default: nil, disabled)
// Encryptor, if set, encrypts every secret before it reaches Store and
// decrypts it immediately after reading one back — see WithEncryptor.
// Default: nil, meaning NO ENCRYPTION. Credential.Secret then reaches
// Store as the same base32 plaintext this package has always written,
// which is NOT safe for a production deployment of a real second
// factor: a leaked store yields every enrolled secret, usable
// indefinitely and silently, with no work factor standing between the
// leak and every account it can now generate valid codes for.
//
// Turning this on for the first time on an existing deployment does
// NOT retroactively encrypt rows already on file, and does not read
// them as plaintext either: ConfirmEnrollment/Validate's decryptSecret
// call fails closed against a pre-Encryptor row (a decode or unknown
// key-ID error — AESEncryptor never mistakes an unrecognized value for
// its own plaintext), so that enrollment simply stops working. The
// recovery path is re-enrollment, not a migration step this package
// performs for you — see the README's "Encrypting stored secrets".
Encryptor Encryptor
}
Config holds TOTP generation parameters.
type Credential ¶
type Credential struct {
ID string
UserID string
// Secret is the base32-encoded shared secret, UNLESS Service is
// configured with an Encryptor (see WithEncryptor), in which case every
// value a Store implementation ever sees here is that Encryptor's
// ciphertext instead — Service encrypts before every write and decrypts
// after every read, entirely inside the totp package. A Store
// implementation cannot tell the difference and does not need to: this
// field is an opaque string either way, and the Store interface below
// is unchanged by whether encryption is configured.
Secret string
Verified bool // true after the user confirms enrollment with a valid code
LastUsedCounter uint64 // time-step counter of the last accepted code, for replay protection
CreatedAt time.Time
}
Credential represents a user's TOTP enrollment — either the active (verified) factor Validate checks codes against, or a pending (unverified) enrollment awaiting ConfirmEnrollment. Which one a given Credential is depends on which Store method returned it (GetActiveTOTP vs GetPendingTOTP), not on any field here: Verified is true on every Credential GetActiveTOTP returns and false on every Credential GetPendingTOTP returns.
type Encryptor ¶
type Encryptor interface {
// Encrypt returns an opaque string encoding plaintext such that only
// Decrypt (with the right key) can recover it. Two calls with the same
// plaintext MUST produce different ciphertexts — via a random nonce,
// for instance — so equal secrets are never visible as equal strings to
// whatever ends up storing the result.
Encrypt(plaintext string) (string, error)
// Decrypt reverses Encrypt. It MUST fail closed: a wrong key, corrupted
// or truncated ciphertext, or any other anomaly must return a non-nil
// error, never a plausible-looking but wrong plaintext. A caller that
// forgets to check the error must never silently receive garbage in
// place of a secret.
Decrypt(ciphertext string) (string, error)
}
Encryptor encrypts and decrypts the string Credential.Secret carries before Service ever hands it to a Store, and decrypts it immediately after reading one back — see WithEncryptor. A Store implementation never sees a usable TOTP secret, and its author never has to think about this protection at all: the protection lives entirely inside this package.
Both methods operate on and return plain strings, exactly the type Credential.Secret already is, so a configured Encryptor's output round-trips through Store completely unchanged — no store contract, schema, or column type needs to know encryption exists.
type Limiter ¶
Limiter enforces a rate limit for a caller-supplied key. It is declared separately from (and identical to) the root package's Limiter interface so this package has no dependency on the root module; a single implementation satisfies both via structural typing. Allow returns a non-nil error if the key should be denied.
type Option ¶
type Option func(*Config)
Option is a functional option for configuring the TOTP service.
func WithDigits ¶
WithDigits sets the number of digits in the code (6 or 8).
func WithEncryptor ¶
WithEncryptor configures the Encryptor Service uses to protect every secret before it reaches Store and immediately after reading one back: Enroll, ReplaceEnrollment, ConfirmEnrollment, and Validate's counter-bump save all go through it, so a Store implementation never receives, stores, or reads back a usable secret — the protection does not depend on the store author. See AESEncryptor for the AES-256-GCM implementation this package provides, including key rotation.
The default is nil: no encryption, so Credential.Secret reaches Store as plaintext, exactly as before this option existed. Configure a real Encryptor before relying on this package for a production second factor.
func WithLimiter ¶
WithLimiter sets the rate limiter consulted before Validate or ConfirmEnrollment checks a code, keyed by "totp:"+userID — the 10^6 code space (or smaller, for 6-digit codes) is guessable without one. A nil limiter (the default) disables rate limiting.
func WithSecretSize ¶
WithSecretSize sets the number of random bytes for new secrets.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service manages TOTP enrollment and validation.
func NewService ¶
NewService creates a new TOTP service.
func (*Service) ConfirmEnrollment ¶
ConfirmEnrollment verifies a code against the user's pending enrollment and, if it matches, atomically promotes that enrollment to the active credential Validate checks codes against (Store.ConfirmEnrollment).
ConfirmEnrollment returns ErrTOTPNotEnrolled — the same as if nothing were pending at all — in two distinct situations callers must not conflate:
- Racing enrollment: the pending enrollment was superseded by a concurrent Enroll or ReplaceEnrollment between the read here and the promotion, so the code that was validated no longer names anything current to promote.
- Retry of an already-succeeded confirm: this is also the common case in practice — a double-submitted confirmation form, or an HTTP response dropped after the server already committed the promotion. Once a pending enrollment is promoted, its slot is consumed exactly once (mirroring the single-use contract ConsumeToken/ConsumeChallenge place on their own resources); a second call with the same code finds no pending enrollment left and returns ErrTOTPNotEnrolled even though the user genuinely is enrolled and the first call's factor is active and working.
Because of (2), an HTTP layer built on this must not render ErrTOTPNotEnrolled from ConfirmEnrollment as "you are not enrolled" — check the user's current enrollment status (e.g. via GetActiveTOTP) before deciding how to react to a confirm retry, rather than trusting the error alone.
func (*Service) Enroll ¶
func (s *Service) Enroll(ctx context.Context, userID, accountName string) (secret, uri string, err error)
Enroll generates a new TOTP secret for the user and returns the base32-encoded secret and an otpauth:// URI suitable for QR code generation. The enrollment is stored as pending — not active, so Validate will not accept codes for it — until ConfirmEnrollment verifies the first code.
Enroll refuses with ErrTOTPAlreadyEnrolled if the user already has an active (verified) TOTP credential. A single stray call — a double-submitted form, a CSRF'd POST, a retried request — must not be able to silently replace a working second factor with an unconfirmed one; use ReplaceEnrollment when the caller explicitly intends to supersede an existing factor.
Enrollment changes a security-relevant setting for the account: callers should gate this endpoint behind recent re-authentication — sulis.Sulis.RequireRecentAuth against the caller's session, refreshed via sulis.Sulis.ReAuthenticate — rather than a bare session.
func (*Service) Generate ¶
Generate produces the current TOTP code for the given base32-encoded secret. This is useful for testing; in production, the authenticator app generates codes.
func (*Service) ReplaceEnrollment ¶
func (s *Service) ReplaceEnrollment(ctx context.Context, userID, accountName string) (secret, uri string, err error)
ReplaceEnrollment generates a new TOTP secret for the user and stores it as a pending enrollment, exactly like Enroll — except it succeeds even when the user already has an active (verified) TOTP credential. This is the explicit "I mean to replace my existing factor" path: the existing active credential is left completely untouched, and Validate keeps accepting codes for it, until ConfirmEnrollment verifies a code for the new secret and promotes it.
Enrollment changes a security-relevant setting for the account: callers should gate this endpoint behind recent re-authentication — sulis.Sulis.RequireRecentAuth against the caller's session, refreshed via sulis.Sulis.ReAuthenticate — rather than a bare session.
func (*Service) Validate ¶
Validate checks a TOTP code for an enrolled, verified user. It returns nil if and only if the code is valid; every rejection reason is a distinct, non-nil error, so callers that only branch on `err != nil` reject a wrong code correctly instead of treating a (false, nil)-shaped "not valid, but no error" result as success.
To prevent replay, a code is only accepted once: its time-step counter must be strictly greater than the last accepted counter for this credential. Reusing a previously accepted code (or an older one, once a newer counter has been accepted) returns ErrTOTPReplayed, distinguishable from a wrong code (ErrTOTPInvalid) via errors.Is.
type Store ¶
type Store interface {
// GetActiveTOTP returns userID's active (verified) credential — the
// one Validate checks codes against. Returns ErrTOTPNotEnrolled if
// userID has no active credential, whether or not a pending
// enrollment exists.
GetActiveTOTP(ctx context.Context, userID string) (*Credential, error)
// GetPendingTOTP returns userID's pending (unverified) enrollment
// awaiting ConfirmEnrollment, if any. Returns ErrTOTPNotEnrolled if
// none exists.
GetPendingTOTP(ctx context.Context, userID string) (*Credential, error)
// EnrollPending atomically stores cred as userID's new pending
// enrollment, after first checking that userID has no active
// credential. The check and the write MUST happen as a single atomic
// operation with respect to any concurrent call for the same userID —
// the same requirement TokenStore.ConsumeToken,
// ChallengeStore.ConsumeChallenge, and passkey.Store.DeleteCredential
// already place on their own check-and-mutate operations, for the same
// reason: a separate read-then-write would let a concurrent
// ConfirmEnrollment promote some OTHER pending enrollment to active in
// the gap between this method's check and its write, only for this
// write to land undetected immediately after.
//
// Returns ErrTOTPAlreadyEnrolled if userID already has an active
// credential; Service.Enroll surfaces this unchanged. Use
// ReplacePending instead when the caller explicitly intends to
// supersede an existing active credential.
//
// Any pending enrollment already on file for userID is unconditionally
// superseded either way — at most one pending enrollment exists per
// user, and an unconfirmed enrollment has nothing worth protecting.
//
// Reference SQL: run the existence check and the upsert as one
// statement or inside one transaction, e.g.
//
// INSERT INTO totp_pending (user_id, id, secret, created_at)
// SELECT $1, $2, $3, $4
// WHERE NOT EXISTS (
// SELECT 1 FROM totp_active WHERE user_id = $1
// )
// ON CONFLICT (user_id) DO UPDATE
// SET id = EXCLUDED.id, secret = EXCLUDED.secret, created_at = EXCLUDED.created_at
//
// and check the affected-row count: 0 rows means an active credential
// already exists, so return ErrTOTPAlreadyEnrolled instead of treating
// it as a generic no-op. A single-threaded or mutex-guarded in-memory
// store can simply perform the check and the write while holding the
// same lock.
EnrollPending(ctx context.Context, cred *Credential) error
// ReplacePending is EnrollPending without the active-credential guard:
// it unconditionally stores cred as userID's new pending enrollment,
// whether or not an active credential exists, and leaves any existing
// active credential completely untouched — Validate keeps checking
// codes against it until a later ConfirmEnrollment promotes cred. This
// is the explicit "I already have a factor and I mean to replace it"
// path Service.ReplaceEnrollment uses.
ReplacePending(ctx context.Context, cred *Credential) error
// ConfirmEnrollment atomically promotes userID's pending enrollment to
// active — but only if it is still the exact enrollment identified by
// pendingID, the ID of the pending credential the caller fetched (via
// GetPendingTOTP) and validated a code against. Implementations MUST
// perform the ID comparison and the promotion (remove the pending
// enrollment, install it as active) as one atomic operation with
// respect to any concurrent call for the same userID — the same
// requirement TokenStore.ConsumeToken, ChallengeStore.ConsumeChallenge,
// and passkey.Store.DeleteCredential place on their own
// check-and-mutate operations. Without it, a concurrent EnrollPending
// or ReplacePending call could overwrite userID's pending enrollment in
// the gap between Service.ConfirmEnrollment reading it (to validate a
// code against its secret) and this method committing the promotion —
// this method would then either promote a pending enrollment nobody
// actually validated a code against, or silently discard a fresh
// enrollment attempt that landed in that gap, without either caller
// ever finding out. This is the atomic operation that closes the
// clobber race described in the T302 task brief.
//
// counter is the time-step counter Service has already matched the
// submitted code against, for the pending credential's own secret. If
// userID already has an active credential (ConfirmEnrollment is
// confirming a ReplaceEnrollment, not a first enrollment), the
// promoted credential's LastUsedCounter MUST be set to whichever is
// greater of counter and the prior active credential's
// LastUsedCounter — never lower — so that replacing a factor can never
// roll a user's replay-protection clock backward.
//
// Returns ErrTOTPNotEnrolled if userID's current pending enrollment's
// ID no longer matches pendingID — already promoted by a concurrent
// call, superseded by a racing EnrollPending/ReplacePending, or never
// existed. The caller treats this exactly like "nothing to confirm."
//
// Reference SQL, in one transaction:
//
// WITH moved AS (
// DELETE FROM totp_pending WHERE user_id = $1 AND id = $2
// RETURNING id, secret, created_at
// )
// INSERT INTO totp_active (user_id, id, secret, verified, last_used_counter, created_at)
// SELECT $1, moved.id, moved.secret, true,
// GREATEST($3, COALESCE(
// (SELECT last_used_counter FROM totp_active WHERE user_id = $1), 0)),
// moved.created_at
// FROM moved
// ON CONFLICT (user_id) DO UPDATE
// SET id = EXCLUDED.id, secret = EXCLUDED.secret, verified = true,
// last_used_counter = EXCLUDED.last_used_counter
//
// Check the DELETE's affected-row count: 0 rows means pendingID no
// longer matches, so return ErrTOTPNotEnrolled without touching
// totp_active.
ConfirmEnrollment(ctx context.Context, userID, pendingID string, counter uint64) (*Credential, error)
// SaveTOTP persists an update to an existing ACTIVE credential — in
// practice, Validate's post-check LastUsedCounter bump. Implementations
// MUST persist LastUsedCounter atomically with respect to concurrent
// calls, and MUST reject (fail closed) any save that would lower
// LastUsedCounter for the active credential with the same ID, so two
// racing validates cannot both win.
SaveTOTP(ctx context.Context, cred *Credential) error
// DeleteTOTP removes both userID's active credential and any pending
// enrollment. Implementations MUST remove both in one atomic operation:
// a separate delete-active-then-delete-pending (or vice versa) would let
// a concurrent ConfirmEnrollment promotion land in the gap between the
// two deletes, leaving the just-promoted credential behind as a
// resurrected "active" factor the caller believed it had just removed
// entirely.
DeleteTOTP(ctx context.Context, userID string) error
}
Store defines the persistence operations for TOTP credentials. It keeps a user's active (verified) factor and pending (unverified) enrollment as two distinct slots — at most one of each per user — so that a stray or racing enrollment attempt can never silently replace an already-verified factor. EnrollPending and ConfirmEnrollment below document exactly where the atomicity that separation depends on must live.