NextMyDay/internal/core/auth/totp/totp.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

123 lines
3.7 KiB
Go

// Package totp owns MyDay's time-based one-time-password factor (D-005): it
// issues an identity's secret and verifies a code.
//
// The secret is never stored in plaintext: Begin hands the secret to the Store,
// which seals it through the existing AEAD sealer (A9). The plaintext lives
// only transiently in memory and in the one otpauth URL the user scans.
//
// Spec: docs/specs/spec-006-enroll-login.md §4 (A9).
package totp
import (
"context"
"errors"
"fmt"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
// ErrInvalidCode is returned when a code does not match the stored secret.
var ErrInvalidCode = errors.New("totp: invalid code")
// Store persists an identity's TOTP secret. It is implemented by the auth
// service, which encrypts at rest through the AEAD sealer (A9) — so this
// package never touches the ciphertext or the key.
type Store interface {
PutTOTPSecret(ctx context.Context, identityID int64, secret []byte) error
TOTPSecret(ctx context.Context, identityID int64) ([]byte, error)
MarkTOTPVerified(ctx context.Context, identityID int64) error
}
// Issued is the outcome of Begin. URL embeds the secret — it is the one display
// of it (the QR code); it must never be logged.
type Issued struct {
URL string
}
// Service is the TOTP seam.
type Service interface {
// Begin issues and stores (encrypted) a fresh secret for identityID.
Begin(ctx context.Context, identityID int64, account string) (Issued, error)
// Verify checks code against the stored secret; on success it records the
// verification.
Verify(ctx context.Context, identityID int64, code string) error
}
// Generator is the real Service, backed by github.com/pquerna/otp.
type Generator struct {
issuer string
store Store
now func() time.Time
opts totp.ValidateOpts
}
var _ Service = (*Generator)(nil)
// New builds the TOTP service. issuer labels the entry in the authenticator
// app; store is the (encrypting) auth service.
func New(issuer string, store Store) *Generator {
if issuer == "" {
issuer = "MyDay"
}
return &Generator{
issuer: issuer,
store: store,
now: time.Now,
opts: totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
},
}
}
// SetClock injects a clock (tests).
func (g *Generator) SetClock(fn func() time.Time) {
if fn != nil {
g.now = fn
}
}
// Begin issues a fresh secret, stores it encrypted (A9) and returns the otpauth
// URL for the authenticator app.
func (g *Generator) Begin(ctx context.Context, identityID int64, account string) (Issued, error) {
if g.store == nil {
return Issued{}, errors.New("totp: no store configured")
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: g.issuer, AccountName: account})
if err != nil {
return Issued{}, fmt.Errorf("totp: generate: %w", err)
}
// The Store seals this through the AEAD sealer before it reaches the DB.
if err := g.store.PutTOTPSecret(ctx, identityID, []byte(key.Secret())); err != nil {
return Issued{}, err
}
return Issued{URL: key.URL()}, nil
}
// Verify validates code against the stored secret, then records the
// verification. The code and the secret never reach an error string or a log.
func (g *Generator) Verify(ctx context.Context, identityID int64, code string) error {
if code == "" {
return ErrInvalidCode
}
secret, err := g.store.TOTPSecret(ctx, identityID)
if err != nil {
return err
}
ok, err := totp.ValidateCustom(code, string(secret), g.now().UTC(), g.opts)
if err != nil {
// A malformed code is an invalid code, not a server fault.
return ErrInvalidCode
}
if !ok {
return ErrInvalidCode
}
if err := g.store.MarkTOTPVerified(ctx, identityID); err != nil {
return err
}
return nil
}