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
- func DecodeSecretBase32(s string) ([]byte, error)
- func EncodeSecretBase32(secret []byte) (string, error)
- func GenerateOTPCode(digits int) (string, error)
- func GenerateTOTPSecret(n int) ([]byte, error)
- func HOTPCode(key []byte, counter uint64, alg Algorithm, digits int) (string, error)
- func HashOTPCode(salt, code string) string
- func TOTPCodeAt(secret []byte, t time.Time, opts TOTPOptions) (string, error)
- func VerifyOTPCode(salt, code, wantHash string) bool
- func VerifyTOTPAt(secret []byte, code string, t time.Time, opts TOTPOptions) (bool, error)
- type Algorithm
- type ChallengePolicy
- func (p ChallengePolicy) AttemptsExhausted(st ChallengeState) bool
- func (p ChallengePolicy) Evaluate(st ChallengeState, now time.Time) ChallengeStatus
- func (p ChallengePolicy) Expired(st ChallengeState, now time.Time) bool
- func (p ChallengePolicy) ExpiresAt(issuedAt time.Time) time.Time
- func (p ChallengePolicy) MaxAttemptsOrDefault() int
- func (p ChallengePolicy) TTLOrDefault() time.Duration
- type ChallengeState
- type ChallengeStatus
- type FakeSender
- type Sender
- type TOTPOptions
Constants ¶
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.
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.
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.
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 ¶
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 ¶
EncodeSecretBase32 renders secret as unpadded base32 text, the conventional wire form for authenticator-app provisioning (otpauth:// URIs, QR codes).
func GenerateOTPCode ¶
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 ¶
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 ¶
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 ¶
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 ¶
TOTPCodeAt computes the RFC 6238 TOTP value for secret at instant t under opts (zero-valued fields resolve to defaults).
func VerifyOTPCode ¶
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 ¶
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.
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 ¶
func (p ChallengePolicy) Evaluate(st ChallengeState, now time.Time) ChallengeStatus
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.
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 ¶
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.