mfa

package
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 10, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package mfa provides reusable, standards-compliant multi-factor-authentication factor primitives: TOTP (RFC 6238) and HOTP (RFC 4226) code generation and verification, numeric one-time-passcode (OTP) generation with salted constant-time hashing, pure challenge-policy helpers (TTL + attempt-limit enforcement), and delivery-port interfaces for out-of-band code senders (SMS/email) with test/log adapters.

This package is a leaf: it has no dependency on kernel/auth, kernel/authz, or any storage/schema concept, and nothing outside it should have to import it to get step-up authorization working. The framework's step-up semantics (kernel/authz's Decision.StepUpRequired, the `amr` claim on authz.Actor) are documented in docs/user-guide/auth.md's "Step-up / MFA" section and consume an auth-methods-reference the *product* asserts after a factor challenge succeeds — kernel/mfa is what a product uses to implement the factor challenge itself (compute/verify a TOTP or OTP code) so it doesn't have to hand-roll HMAC truncation or timing-safe comparisons. The two are linked by convention, not by an import: mfa produces "this code was valid", the product's auth flow turns that into an `amr` entry, kernel/authz/kernel/auth consume the resulting claim.

Deliberately OUT of scope (product-owned):

  • Enrollment UX (QR code rendering, backup codes, recovery flows).
  • Factor storage schema (how/where a product persists secrets, challenge rows, attempt counters — this package's ChallengeState is an in-memory value the caller supplies from wherever it stores state).
  • Delivery provider selection (which SMS/email vendor, retry policy, rate limiting) — only the Sender port and a log/fake adapter are here.
  • Policy decisions (which actions require which factor, whether a factor satisfies a given permission's step_up requirement).

Index

Constants

View Source
const (
	DefaultChallengeTTL         = 5 * time.Minute
	DefaultChallengeMaxAttempts = 5
)

Default challenge policy values. A delivered (SMS/email) OTP challenge is conventionally short-lived and attempt-limited; these mirror common authenticator/OTP-flow defaults and are overridable per policy.

View Source
const (
	DefaultTOTPStep   = 30 * time.Second
	DefaultTOTPDigits = 6
	DefaultTOTPSkew   = 1
)

Default TOTP parameters (RFC 6238's own defaults, and the common authenticator-app convention): 30s step, 6 digits, SHA-1, ±1 step of clock-skew tolerance on verify.

View Source
const DefaultOTPDigits = 6

DefaultOTPDigits is the conventional length of a delivered (SMS/email) one-time code — shorter than a TOTP code since it is single-use, short-lived, and attempt-limited rather than continuously re-derived.

View Source
const DefaultTOTPSecretLen = 20

DefaultTOTPSecretLen is the recommended HOTP/TOTP secret length in bytes (160 bits, RFC 4226's recommended key size).

Variables

This section is empty.

Functions

func DecodeSecretBase32

func DecodeSecretBase32(s string) ([]byte, error)

DecodeSecretBase32 parses a base32-encoded secret. It is case-insensitive and tolerates surrounding whitespace (common when a user copy-pastes a secret), matching how authenticator apps typically accept manual entry.

func EncodeSecretBase32

func EncodeSecretBase32(secret []byte) (string, error)

EncodeSecretBase32 renders secret as unpadded base32 text, the conventional wire form for authenticator-app provisioning (otpauth:// URIs, QR codes).

func GenerateOTPCode

func GenerateOTPCode(digits int) (string, error)

GenerateOTPCode returns a cryptographically random (crypto/rand) numeric code of exactly digits decimal characters, left-zero-padded. digits must be in [1,10] (bounded by the same limit as HOTPCode's dynamic-truncation output width).

The modulus and the random draw are both computed in uint64: digits=10 requires a modulus of 10^10 (10000000000), which exceeds uint32's range (max 4294967295) and would silently wrap to a much smaller modulus if accumulated in 32 bits — the same class of bug this package's HOTPCode once had. 8 random bytes read into a uint64 give ~64 bits of entropy, comfortably enough to reduce mod 10^10 without the last-bucket modulo bias a narrower read would introduce.

func GenerateTOTPSecret

func GenerateTOTPSecret(n int) ([]byte, error)

GenerateTOTPSecret returns n cryptographically random bytes suitable for use as a TOTP/HOTP shared secret (crypto/rand). n must be positive; 20 (DefaultTOTPSecretLen) matches RFC 4226's recommended 160-bit key.

func HOTPCode

func HOTPCode(key []byte, counter uint64, alg Algorithm, digits int) (string, error)

HOTPCode computes the RFC 4226 HOTP value for key and counter using the given algorithm, truncated to digits decimal digits (left-zero-padded). digits must be in [1,10]; key must be non-empty.

func HashOTPCode

func HashOTPCode(salt, code string) string

HashOTPCode returns a salted SHA-256 hash of code, hex-encoded. salt should be a value unique to the challenge (e.g. the challenge's random ID) so an offline attacker cannot precompute a rainbow table across challenges; it is not a substitute for TTL + attempt-limit enforcement (see ChallengePolicy), which is what actually bounds brute-force exposure for a short numeric code space.

func TOTPCodeAt

func TOTPCodeAt(secret []byte, t time.Time, opts TOTPOptions) (string, error)

TOTPCodeAt computes the RFC 6238 TOTP value for secret at instant t under opts (zero-valued fields resolve to defaults).

func VerifyOTPCode

func VerifyOTPCode(salt, code, wantHash string) bool

VerifyOTPCode reports whether code, salted the same way, matches wantHash. The comparison is constant-time (crypto/subtle) to avoid leaking hash-prefix-match timing to an attacker probing over the network.

func VerifyTOTPAt

func VerifyTOTPAt(secret []byte, code string, t time.Time, opts TOTPOptions) (bool, error)

VerifyTOTPAt reports whether code is valid for secret at instant t, tolerating ±opts.Skew steps of clock skew (zero-valued fields resolve to defaults). The comparison against each candidate code is constant-time (crypto/subtle), so verification timing does not leak how close an incorrect guess was.

Types

type Algorithm

type Algorithm string

Algorithm is the HMAC hash function underlying an HOTP/TOTP code. RFC 4226 defines SHA-1; RFC 6238 §5.2 extends the same construction to SHA-256 and SHA-512 for TOTP. This is a closed set — an unrecognized value is rejected rather than silently falling back to a default, since silently using the wrong algorithm produces a code that verifies against nothing.

const (
	AlgSHA1   Algorithm = "SHA1"
	AlgSHA256 Algorithm = "SHA256"
	AlgSHA512 Algorithm = "SHA512"
)

type ChallengePolicy

type ChallengePolicy struct {
	// TTL is how long a challenge remains valid after issuance. Zero
	// resolves to DefaultChallengeTTL.
	TTL time.Duration
	// MaxAttempts is the number of verification attempts allowed before the
	// challenge is locked out. Zero resolves to DefaultChallengeMaxAttempts.
	MaxAttempts int
}

ChallengePolicy is pure TTL + attempt-limit enforcement logic for a delivered-code challenge (SMS/email OTP or similar). It holds no state and touches no storage — a product owns the challenge row/cache entry and calls Evaluate with the state it loaded to decide whether to accept a verification attempt.

func (ChallengePolicy) AttemptsExhausted

func (p ChallengePolicy) AttemptsExhausted(st ChallengeState) bool

AttemptsExhausted reports whether st has reached or exceeded the attempt limit.

func (ChallengePolicy) Evaluate

Evaluate returns the single ChallengeStatus describing whether st may still be verified as of now. Priority when multiple conditions hold: consumed > expired > attempts-exhausted > ok — a consumed challenge is permanently done regardless of timing, and an expired challenge is reported as expired even if attempts also happen to be exhausted (the caller-visible reason should be the one that would have applied first in time: consumption and expiry are absolute facts, exhaustion is a count-based fact that stops mattering once the challenge would have expired anyway).

func (ChallengePolicy) Expired

func (p ChallengePolicy) Expired(st ChallengeState, now time.Time) bool

Expired reports whether a challenge in state st has passed its TTL as of now.

func (ChallengePolicy) ExpiresAt

func (p ChallengePolicy) ExpiresAt(issuedAt time.Time) time.Time

ExpiresAt returns the instant a challenge issued at issuedAt expires under p.

func (ChallengePolicy) MaxAttemptsOrDefault

func (p ChallengePolicy) MaxAttemptsOrDefault() int

MaxAttemptsOrDefault returns p.MaxAttempts, or DefaultChallengeMaxAttempts if unset.

func (ChallengePolicy) TTLOrDefault

func (p ChallengePolicy) TTLOrDefault() time.Duration

TTLOrDefault returns p.TTL, or DefaultChallengeTTL if unset.

type ChallengeState

type ChallengeState struct {
	// IssuedAt is when the challenge was created.
	IssuedAt time.Time
	// Attempts is the number of verification attempts made so far.
	Attempts int
	// Consumed is true once the challenge has been successfully verified
	// (a consumed challenge must not be reusable).
	Consumed bool
}

ChallengeState is the caller-supplied, product-persisted state of a single in-flight challenge (an issued TOTP/OTP code awaiting verification). This package has no storage schema of its own — a product loads these fields from wherever it persists challenges (its own table, cache, etc.) and passes them to ChallengePolicy for evaluation.

type ChallengeStatus

type ChallengeStatus int

ChallengeStatus is the outcome of evaluating a ChallengeState against a ChallengePolicy.

const (
	// ChallengeOK means the challenge is still live: not expired, not
	// attempt-exhausted, not consumed — the caller may attempt verification.
	ChallengeOK ChallengeStatus = iota
	// ChallengeExpired means the challenge's TTL has elapsed.
	ChallengeExpired
	// ChallengeAttemptsExhausted means the attempt limit has been reached
	// or exceeded.
	ChallengeAttemptsExhausted
	// ChallengeConsumed means the challenge was already successfully
	// verified and must not be reused.
	ChallengeConsumed
)

func (ChallengeStatus) OK

func (s ChallengeStatus) OK() bool

OK reports whether the status permits a further verification attempt.

func (ChallengeStatus) String

func (s ChallengeStatus) String() string

String implements fmt.Stringer for readable test failures/log lines.

type FakeSender

type FakeSender struct {
	Deliveries []delivery
	// Err, if non-nil, is returned by every Send call (and the call is NOT
	// recorded, matching "delivery failed" semantics).
	Err error
	// contains filtered or unexported fields
}

FakeSender is an in-memory Sender for tests: it records every Send call and returns the configured Err (if any) instead of delivering anything.

func (*FakeSender) Count

func (f *FakeSender) Count() int

Count returns the number of successfully recorded deliveries.

func (*FakeSender) LastCode

func (f *FakeSender) LastCode() string

LastCode extracts the last whitespace-separated token of the most recent delivery's body — a convenience for tests that send a "...code is 123456" style message and want the code back without re-deriving it via a second channel.

func (*FakeSender) Reset

func (f *FakeSender) Reset()

Reset clears recorded deliveries and the configured Err.

func (*FakeSender) Send

func (f *FakeSender) Send(_ context.Context, destination, body string) error

Send records the delivery, or returns f.Err without recording.

type Sender

type Sender interface {
	// Send delivers body to destination. Errors are returned to the caller;
	// this package does not retry — retry/backoff policy is product-owned.
	Send(ctx context.Context, destination, body string) error
}

Sender is the delivery port for an out-of-band factor code (SMS or email body text — the destination address format is caller-defined: a phone number for SMS, an email address for email). Real provider adapters (Twilio, SES, etc.) are product territory; this package only defines the shape and ships a log adapter (for local/dev) and a fake (for tests), mirroring how kernel/notify.ChannelSender separates the port from any concrete transport. mfa.Sender is intentionally NOT kernel/notify's ChannelSender: notify's port is keyed to a persisted Delivery row from the notification_deliveries schema, which is exactly the storage coupling this leaf package must not take on.

func NewLogSender

func NewLogSender(log *slog.Logger) Sender

NewLogSender returns the logging dev/local Sender adapter.

type TOTPOptions

type TOTPOptions struct {
	// Step is the time-step duration (RFC 6238 calls this X). Zero resolves
	// to DefaultTOTPStep.
	Step time.Duration
	// Digits is the number of decimal digits in the code. Zero resolves to
	// DefaultTOTPDigits.
	Digits int
	// Algorithm is the HMAC hash. Empty resolves to AlgSHA1.
	Algorithm Algorithm
	// Skew is the number of steps of clock skew tolerated on verify in
	// either direction (0 = exact step only). Negative is rejected.
	Skew int
}

TOTPOptions configures TOTP code generation/verification. A zero-valued TOTPOptions resolves to the package defaults (30s/6 digits/SHA1/skew=1) via withDefaults — callers only need to set the fields they want to override.

Jump to

Keyboard shortcuts

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