totp

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 18 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

func (*AESEncryptor) Decrypt

func (e *AESEncryptor) Decrypt(ciphertext string) (string, error)

Decrypt implements Encryptor.

func (*AESEncryptor) Encrypt

func (e *AESEncryptor) Encrypt(plaintext string) (string, error)

Encrypt implements Encryptor.

type Algorithm

type Algorithm int

Algorithm identifies the HMAC hash algorithm.

const (
	AlgorithmSHA1 Algorithm = iota // default, most widely supported
	AlgorithmSHA256
	AlgorithmSHA512
)

func (Algorithm) String

func (a Algorithm) String() string

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

type Limiter interface {
	Allow(ctx context.Context, key string) error
}

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 WithAlgorithm

func WithAlgorithm(a Algorithm) Option

WithAlgorithm sets the HMAC algorithm.

func WithDigits

func WithDigits(d int) Option

WithDigits sets the number of digits in the code (6 or 8).

func WithEncryptor

func WithEncryptor(e Encryptor) Option

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

func WithLimiter(l Limiter) Option

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 WithPeriod

func WithPeriod(p uint64) Option

WithPeriod sets the time step in seconds.

func WithSecretSize

func WithSecretSize(n int) Option

WithSecretSize sets the number of random bytes for new secrets.

func WithSkew

func WithSkew(s uint) Option

WithSkew sets the number of periods to check before and after the current one.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service manages TOTP enrollment and validation.

func NewService

func NewService(store Store, issuer string, opts ...Option) (*Service, error)

NewService creates a new TOTP service.

func (*Service) ConfirmEnrollment

func (s *Service) ConfirmEnrollment(ctx context.Context, userID, code string) error

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:

  1. 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.
  2. 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

func (s *Service) Generate(secret string, t time.Time) (string, error)

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) Unenroll

func (s *Service) Unenroll(ctx context.Context, userID string) error

Unenroll removes TOTP enrollment for a user.

func (*Service) Validate

func (s *Service) Validate(ctx context.Context, userID, code string) error

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL