mfa

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package mfa owns typed TOTP/recovery factors and model-first factor management. Its manager rechecks passwords, locks models before factors, applies a separate MFA throttle, and persists replay/consumption with model changes and credential revocation. PostgreSQL stores encrypted TOTP secrets through explicit migrations. Management never issues authenticated credentials. TOTP uses six-digit HMAC-SHA1 with a 30-second period; inputs and results preserve model and factor types.

Index

Constants

View Source
const DefaultRecoveryCodes = 10
View Source
const MaxEnrollmentLifetime = time.Hour
View Source
const MaxPrune = 128
View Source
const MaxRecoveryCodes = 16
View Source
const MaxRotationBatch = 256

MaxRotationBatch bounds the factors examined by one ReencryptStale call.

Variables

This section is empty.

Functions

func ProvisioningURI

func ProvisioningURI(key TOTPSecret, issuer, account string) (secret.String, error)

ProvisioningURI constructs the otpauth URI for an authenticated enrollment response or local QR renderer. The URI contains the TOTP secret; never pass it to a remote QR-generation service, analytics or logging. Issuer/account labels must be nonempty, bounded, colon-free UTF-8 without control characters.

Types

type Action

type Action string

Action identifies an MFA security observation, never a credential or authority.

const (
	Enrolled            Action = "enrolled"
	Disabled            Action = "disabled"
	Verified            Action = "verified"
	RecoveryRegenerated Action = "recovery_regenerated"
	Reencrypted         Action = "reencrypted"
	Retired             Action = "retired"
	Rejected            Action = "rejected"
)

type Address

type Address struct {
	Namespace keyspace.Namespace
	Provider  auth.ProviderName
	Model     string
}

Address is the persistence boundary. A model/provider shares one TOTP factor across its guards; guard names do not create independent factor enrollments.

func (Address) Key

func (a Address) Key() (string, error)

func (Address) SubjectKey

func (a Address) SubjectKey(identity model.Identity) (string, error)

func (Address) Validate

func (a Address) Validate() error

type Backend

Backend owns one read-committed transaction. Within invokes prepare exactly once to lock the application model BEFORE reading/locking the factor record. That model lock serializes creation even when no factor row exists. It then calls change once with owned state and server time, validates its result and deadline, persists it and commits. Callback errors, panic/Goexit or cancellation roll back all database changes. Never suppress failures, detach callbacks, retry mutations or acquire model locks after factor/credential locks.

Callbacks use only the supplied transaction for database writes. The runtime may consult its bounded MFA lockout store before protected writes; external effects (email, publishing) require an outbox. After-commit failure may retain a Committed database outcome; an error is not permission to retry blindly. Prune deletes only expired pending factors and acquires no model locks.

type Change

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

Change explicitly selects replacement or removal. Zero is invalid. Before adds a strict pre-commit deadline, used to prevent late enrollment confirmation.

func Remove

func Remove() Change

func Replace

func Replace(record Record) Change

func (Change) Before

func (c Change) Before(until temporal.DateTime) Change

func (Change) Deadline

func (c Change) Deadline() value.Optional[temporal.DateTime]

func (Change) Next

func (c Change) Next() value.Optional[Record]

func (Change) Removes

func (c Change) Removes() bool

func (Change) Validate

func (c Change) Validate(address Address, subject model.Identity) error

type Config

type Config struct {
	Namespace          keyspace.Namespace
	Issuer             string
	EnrollmentLifetime time.Duration
	Window             int
	RecoveryCodes      int
	MaxConcurrent      int
	Timeout            time.Duration
}

func DefaultConfig

func DefaultConfig(namespace keyspace.Namespace, issuer string) Config

func (Config) Validate

func (c Config) Validate() error

type Enrollment

type Enrollment[M any] struct {
	// contains filtered or unexported fields
}

Enrollment is returned only after the pending encrypted factor commits. Secret and URI are explicit disclosure boundaries for the enrolled account.

func (Enrollment[M]) CreatedAt

func (e Enrollment[M]) CreatedAt() temporal.DateTime

func (Enrollment[M]) ExpiresAt

func (e Enrollment[M]) ExpiresAt() temporal.DateTime

func (Enrollment[M]) Format

func (Enrollment[M]) Format(s fmt.State, _ rune)

func (Enrollment[M]) ID

func (e Enrollment[M]) ID() EnrollmentID[M]

func (Enrollment[M]) Secret

func (e Enrollment[M]) Secret() TOTPSecret

func (Enrollment[M]) Subject

func (e Enrollment[M]) Subject() M

func (Enrollment[M]) URI

func (e Enrollment[M]) URI() secret.String

type EnrollmentID

type EnrollmentID[M any] struct {
	// contains filtered or unexported fields
}

EnrollmentID preserves the model type. Parsing identifies a pending factor; it does not authenticate or authorize confirmation.

func ParseEnrollmentID

func ParseEnrollmentID[M any](text string) (EnrollmentID[M], error)

func (EnrollmentID[M]) IsZero

func (id EnrollmentID[M]) IsZero() bool

func (EnrollmentID[M]) JSONContract

func (EnrollmentID[M]) JSONContract() contract.JSON[EnrollmentID[M]]

func (EnrollmentID[M]) MarshalJSON

func (id EnrollmentID[M]) MarshalJSON() ([]byte, error)

MarshalJSON preserves the public enrollment identifier; it is not a secret or proof. The model owner remains part of its Go type and generated contract.

func (EnrollmentID[M]) String

func (id EnrollmentID[M]) String() string

func (*EnrollmentID[M]) UnmarshalJSON

func (id *EnrollmentID[M]) UnmarshalJSON(data []byte) error

type Factors

type Factors[M model.Identifiable, K any] struct {
	// contains filtered or unexported fields
}

Factors owns one model/provider's TOTP factor across guards. A separate typed MFA throttle is mandatory; never reuse the password-attempt declaration.

func New

func New[M model.Identifiable, K any](store *Store, provider auth.Provider[M, K], binding Model[M, K], attempts lockout.Throttle[K]) (*Factors[M, K], error)

func (*Factors[M, K]) Confirm

func (f *Factors[M, K]) Confirm(ctx context.Context, password auth.PasswordResult[M, K], id EnrollmentID[M], code TOTPCode) (RecoveryCodes[M], error)

Confirm accepts only a TOTP code for the current unexpired enrollment. The accepted step, recovery hashes, model flag and all credential revocations commit together. The result is not an authenticated proof or a login response.

func (*Factors[M, K]) Disable

func (f *Factors[M, K]) Disable(ctx context.Context, password auth.PasswordResult[M, K], response Response) (M, error)

Disable requires a rechecked password, current TOTP/recovery factor and domain permission. Factor removal, model mutation and credential revocation share tx.

func (*Factors[M, K]) Enroll

func (f *Factors[M, K]) Enroll(ctx context.Context, password auth.PasswordResult[M, K]) (Enrollment[M], error)

Enroll replaces only an unconfirmed enrollment. The password result is rechecked under the model lock. An enabled/confirmed factor must be verified and disabled first; an ordinary session or bare model reference cannot enroll.

func (*Factors[M, K]) HasEnrolledFactor

func (f *Factors[M, K]) HasEnrolledFactor(_ context.Context, subject M) (bool, error)

HasEnrolledFactor reports the model's stored enabled state. It implements auth.EnrolledFactors for PasswordLogin.WithSecondFactor and performs no I/O.

func (*Factors[M, K]) Prune

func (f *Factors[M, K]) Prune(ctx context.Context, limit int) (uint64, error)

Prune removes a bounded batch of expired, unconfirmed enrollments only. Confirmed factors and application models are never deleted by maintenance.

func (*Factors[M, K]) Reencrypt

func (f *Factors[M, K]) Reencrypt(ctx context.Context, password auth.PasswordResult[M, K], response Response) error

Reencrypt requires password/factor verification, preserves the enrollment ID and recovery set (apart from the code used), and encrypts with the active key. It does not change MFA policy or issue credentials. Retain old keys until every stored factor has migrated; this operation cannot recover a lost key.

func (*Factors[M, K]) RegenerateRecovery

func (f *Factors[M, K]) RegenerateRecovery(ctx context.Context, password auth.PasswordResult[M, K], response Response) (RecoveryCodes[M], error)

RegenerateRecovery verifies a current factor, replaces the complete recovery set and revokes registered credentials atomically. No old recovery code survives.

func (*Factors[M, K]) RetireIn

func (f *Factors[M, K]) RetireIn(ctx context.Context, parent *database.Tx, reference model.Reference[M, K]) (M, error)

RetireIn is an explicitly authorized administrative operation for account retirement. It locks the existing model, removes its pending/confirmed factor, clears Enabled and invalidates every registered credential in the supplied transaction. Delete/retire the domain model in that same transaction AFTER this call. It accepts disabled accounts; it does not bypass a missing model or authenticate the caller. Enforce an administrative policy before invoking it.

Unlike Disable this does not require the retiring user's password/factor or CanDisable permission. Never expose it as an unauthenticated self-service endpoint. Returned state is provisional until outer commit. Rollback restores model, factor and credentials. Do not reuse account keys without retirement.

func (*Factors[M, K]) Validate

func (f *Factors[M, K]) Validate() error

func (*Factors[M, K]) Verifier

func (f *Factors[M, K]) Verifier(response Response) (auth.SecondFactor[M, K], error)

Verifier binds one typed TOTP/recovery response for Sessions.CompleteMFA or Tokens.CompleteMFA. It performs no I/O and grants no authority. Completion locks/resolves the current model once, checks its eligibility and enabled factor, consumes the pending credential and verifies the second factor in the credential creation transaction. A failure rolls back all provisional writes.

func (*Factors[M, K]) WithObserver

func (f *Factors[M, K]) WithObserver(observer Observer[M, K]) (*Factors[M, K], error)

WithObserver returns an immutable view. Register at most once; callbacks must respect cancellation and shared-state concurrency. Notices are not delivery receipts: outbox durability begins only when the enclosing transaction commits.

type Model

type Model[M model.Identifiable, K any] struct {
	Lock         func(context.Context, *database.Tx, K) (value.Optional[M], error)
	Enabled      func(M) bool
	SetEnabled   func(context.Context, *database.Tx, M, bool) (M, error)
	AccountLabel func(M) (string, error)
	CanDisable   func(context.Context, M) (bool, error)
	Invalidate   func(context.Context, *database.Tx, M) error
}

Model maps stored fields and normal generated mutations to MFA. Lock uses a generated ForUpdate query on the supplied transaction and preserves K. SetEnabled returns the complete updated model and must preserve identity. Invalidate joins tx (for example auth.Revocations.Invalidate) and revokes every registered credential guard. CanDisable enforces required-factor domain policy. Link the factors to login with PasswordLogin.WithSecondFactor(factors): an enrolled model then always requires MFA, whatever RequiresMFA returns. Callbacks must not commit, retry, send external messages or acquire other pools.

type Notice

type Notice[M any, K any] struct {
	// contains filtered or unexported fields
}

Notice exposes only the affected typed reference and action. It contains no model snapshot, password, factor secret, submitted code or credential. The initiator remains attribution.FromContext(ctx); affected subject is not actor.

func (Notice[M, K]) Action

func (n Notice[M, K]) Action() Action

func (Notice[M, K]) Format

func (Notice[M, K]) Format(s fmt.State, _ rune)

func (Notice[M, K]) Subject

func (n Notice[M, K]) Subject() model.Reference[M, K]

type Observer

type Observer[M any, K any] struct {
	Changed  func(context.Context, *database.Tx, Notice[M, K]) error
	Rejected func(context.Context, Notice[M, K]) error
}

Observer maps MFA observations to application-owned event DTOs. Changed runs inside the mutation transaction: use events.Topic.Enqueue for durable dispatch, events.Topic.AfterCommit for process-local dispatch, or audit through this tx. Never perform external I/O there. Failure rolls back factor/model/credentials. Rejected is an immediate, process-local observation of a nonmatching factor or confirmed lockout. The enclosing mutation rolls back; do not persist a rejection through that transaction. No callback runs for malformed input, absent/inactive factors, missing models or infrastructure failures. Rejected errors remain causes of the denial and cannot turn it into success.

type Record

type Record struct {
	ID             model.ID[Record]
	Address        Address
	Subject        model.Identity
	Ciphertext     encryption.Ciphertext
	CreatedAt      temporal.DateTime
	PendingUntil   value.Optional[temporal.DateTime]
	ConfirmedAt    value.Optional[temporal.DateTime]
	LastStep       value.Optional[int64]
	RecoveryHashes []RecoveryHash
}

Record is the trusted storage-adapter boundary, never a transport DTO. A pending record has an expiry but no confirmation, replay step or recovery hashes. A confirmed record has no pending expiry. ID changes on reenrollment.

func (Record) Clone

func (r Record) Clone() Record

func (Record) Format

func (Record) Format(s fmt.State, _ rune)

func (Record) LogValue

func (Record) LogValue() slog.Value

func (Record) MarshalJSON

func (Record) MarshalJSON() ([]byte, error)

func (Record) Validate

func (r Record) Validate(address Address, subject model.Identity) error

type RecoveryCode

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

RecoveryCode is a distinct canonical 256-bit random factor encoded as 43 unpadded base64url characters. It is not a password, TOTP code or proof. Only the digest is persisted; consumption must be atomic with factor completion.

func ParseRecoveryCode

func ParseRecoveryCode(raw secret.String) (RecoveryCode, error)

func (RecoveryCode) Format

func (RecoveryCode) Format(s fmt.State, _ rune)

func (RecoveryCode) JSONContract

func (RecoveryCode) JSONContract() contract.JSON[RecoveryCode]

func (RecoveryCode) LogValue

func (RecoveryCode) LogValue() slog.Value

func (RecoveryCode) MarshalJSON

func (RecoveryCode) MarshalJSON() ([]byte, error)

func (RecoveryCode) Secret

func (c RecoveryCode) Secret() secret.String

func (*RecoveryCode) UnmarshalJSON

func (c *RecoveryCode) UnmarshalJSON(data []byte) error

func (RecoveryCode) Validate

func (c RecoveryCode) Validate() error

type RecoveryCodes

type RecoveryCodes[M any] struct {
	// contains filtered or unexported fields
}

RecoveryCodes exposes an owned slice only after the factor mutation commits. Ordinary serialization cannot disclose the private collection or model.

func (RecoveryCodes[M]) Codes

func (r RecoveryCodes[M]) Codes() []RecoveryCode

func (RecoveryCodes[M]) Format

func (RecoveryCodes[M]) Format(s fmt.State, _ rune)

func (RecoveryCodes[M]) ID

func (r RecoveryCodes[M]) ID() EnrollmentID[M]

func (RecoveryCodes[M]) Subject

func (r RecoveryCodes[M]) Subject() M

type RecoveryHash

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

RecoveryHash is a redacted SHA-256 digest of a high-entropy RecoveryCode, sharing the credential-hash implementation used by sessions and tokens. Encoded is an explicit persistence boundary, never an authentication proof.

func ParseRecoveryHash

func ParseRecoveryHash(encoded string) (RecoveryHash, error)

func (RecoveryHash) Encoded

func (h RecoveryHash) Encoded() string

func (RecoveryHash) Format

func (RecoveryHash) Format(s fmt.State, _ rune)

func (RecoveryHash) IsZero

func (h RecoveryHash) IsZero() bool

func (RecoveryHash) LogValue

func (RecoveryHash) LogValue() slog.Value

func (RecoveryHash) MarshalJSON

func (RecoveryHash) MarshalJSON() ([]byte, error)

type Response

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

Response is an explicit choice of TOTP or recovery factor. Enrollment confirmation accepts only TOTPCode and cannot receive this alternative.

func RecoveryResponse

func RecoveryResponse(code RecoveryCode) (Response, error)

func TOTPResponse

func TOTPResponse(code TOTPCode) (Response, error)

func (Response) Format

func (Response) Format(s fmt.State, _ rune)

func (Response) Validate

func (r Response) Validate() error

type Rotation

type Rotation struct {
	Reencrypted uint64
	Changed     uint64
	Failed      uint64
	Next        RotationCursor
	Done        bool
}

Rotation reports one bounded batch. Reencrypted counts committed replacements; Changed counts factors that changed concurrently (a later run re-examines them if still stale); Failed counts envelopes that could not be decrypted with the retained keys. Done reports that no stale factor remained after Next when the batch was read.

type RotationBackend

type RotationBackend interface {
	// StaleCiphertexts returns at most limit factors whose envelope was not
	// produced by the active key, ordered by Owner and strictly after the
	// cursor (empty starts from the beginning).
	StaleCiphertexts(ctx context.Context, active encryption.KeyID, after string, limit int) ([]StoredCiphertext, error)
	// ReplaceCiphertext stores next only while the factor still has the same
	// owner, generation and envelope. False means it changed concurrently.
	ReplaceCiphertext(ctx context.Context, stored StoredCiphertext, next encryption.Ciphertext) (bool, error)
}

RotationBackend is the storage contract for password-free key rotation across every address sharing the store. Implementations never load domain models.

type RotationCursor

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

RotationCursor resumes rotation after the last examined factor. The zero value starts from the beginning. It is opaque and not a model reference.

func (RotationCursor) IsZero

func (c RotationCursor) IsZero() bool

type Store

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

Store borrows a transactional backend and immutable encryption keyring. One store bounds work across all model bindings. Construction performs no I/O.

func NewStore

func NewStore(backend Backend, keys *encryption.Keyring, config Config) (*Store, error)

func (*Store) ReencryptStale

func (s *Store) ReencryptStale(ctx context.Context, after RotationCursor, limit int) (Rotation, error)

ReencryptStale re-encrypts up to limit stored factors under the keyring's active key, for key rotation, without any user's password or domain model. Each replacement is conditional on the factor's generation and envelope, so it is safe alongside logins and management. The secret, generation, replay step and recovery hashes are unchanged, and no credential is revoked. Envelopes whose key is no longer retained are reported as Failed and skipped. Counts committed before an error are returned with it.

func (*Store) Validate

func (s *Store) Validate() error

type StoredCiphertext

type StoredCiphertext struct {
	Owner      string
	Generation model.ID[Record]
	Ciphertext encryption.Ciphertext
}

StoredCiphertext is one stored factor envelope selected for key rotation. It carries the opaque subject key and factor generation that bind the envelope's encryption context, never a model, identity or plaintext.

type TOTPCode

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

TOTPCode preserves six ASCII digits, including leading zeroes. It is a typed unverified input. No trimming, Unicode digit conversion or truncation occurs.

func ParseTOTPCode

func ParseTOTPCode(raw secret.String) (TOTPCode, error)

func (TOTPCode) Format

func (TOTPCode) Format(s fmt.State, _ rune)

func (TOTPCode) JSONContract

func (TOTPCode) JSONContract() contract.JSON[TOTPCode]

func (TOTPCode) LogValue

func (TOTPCode) LogValue() slog.Value

func (TOTPCode) MarshalJSON

func (TOTPCode) MarshalJSON() ([]byte, error)

func (TOTPCode) Secret

func (c TOTPCode) Secret() secret.String

func (*TOTPCode) UnmarshalJSON

func (c *TOTPCode) UnmarshalJSON(data []byte) error

func (TOTPCode) Validate

func (c TOTPCode) Validate() error

type TOTPSecret

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

TOTPSecret is a canonical unpadded uppercase base32 160-bit secret. Only Secret deliberately reveals it for encrypted persistence or authenticated enrollment delivery. Plain database storage and ordinary response serialization are not supported. This type does not promise memory erasure.

func GenerateTOTPSecret

func GenerateTOTPSecret() (TOTPSecret, error)

func ParseTOTPSecret

func ParseTOTPSecret(encoded secret.String) (TOTPSecret, error)

func (TOTPSecret) Format

func (TOTPSecret) Format(s fmt.State, _ rune)

func (TOTPSecret) LogValue

func (TOTPSecret) LogValue() slog.Value

func (TOTPSecret) MarshalJSON

func (TOTPSecret) MarshalJSON() ([]byte, error)

func (TOTPSecret) Secret

func (s TOTPSecret) Secret() secret.String

func (TOTPSecret) Validate

func (s TOTPSecret) Validate() error

type TransactionalBackend

TransactionalBackend joins the credential creation transaction. It verifies exact pool ownership, uses a savepoint, restores schema settings and never commits. The same model-before-factor ordering and callback rules apply.

Directories

Path Synopsis
Package command supplies the explicit MFA key-rotation operation.
Package command supplies the explicit MFA key-rotation operation.
Package postgres stores encrypted MFA factors through explicit migrations and generated model queries.
Package postgres stores encrypted MFA factors through explicit migrations and generated model queries.

Jump to

Keyboard shortcuts

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