NextMyDay/internal/core/auth/totp/totp.go
Claus Lohmar 93ec8b8131 fix(security): reject replayed TOTP codes
REVIEW-001 (M0) L5s: a code valid for ±90s could be replayed. TOTP is a
login fallback (D-005) and the only factor the bootstrap admin has (D-041),
so a code observed once (shoulder-surfed, phished, in a log) must not work
twice.

Track the highest accepted counter per identity and refuse any counter that
is not strictly newer. The search is newest-first and compares in constant
time; a replay is reported as ErrInvalidCode — indistinguishable from a wrong
code, so the response is not an oracle (A3). A fresh secret (re-enrollment)
resets the counter.
2026-10-14 11:29:55 +00:00

192 lines
6 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"
"crypto/subtle"
"errors"
"fmt"
"sync"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
// ErrInvalidCode is returned when a code does not match the stored secret — or
// when it does but has already been accepted (a replay, L5s). The two cases are
// deliberately indistinguishable: never tell an attacker which.
var ErrInvalidCode = errors.New("totp: invalid code")
// errNoStore is returned when the service was built without a Store.
var errNoStore = errors.New("totp: no store configured")
// 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
// mu guards last, the highest counter accepted per identity (L5s). Replay
// defence is in-process: a restart forgets the counters, but the skew
// window is only ±90s, so the exposure is bounded and the alternative (a
// DB round-trip per verification) is not worth it at M0.
mu sync.Mutex
last map[int64]uint64
}
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,
},
last: make(map[int64]uint64),
}
}
// 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. A fresh secret resets the identity's replay
// counter (L5s): the new secret is a new sequence.
func (g *Generator) Begin(ctx context.Context, identityID int64, account string) (Issued, error) {
if g.store == nil {
return Issued{}, errNoStore
}
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
}
g.forget(identityID)
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.
//
// A code that matches but whose counter has already been accepted is rejected
// as a replay (L5s), so a code observed (shoulder-surfed, logged, phished) in
// its 90-second validity cannot be used twice.
func (g *Generator) Verify(ctx context.Context, identityID int64, code string) error {
if code == "" {
return ErrInvalidCode
}
if g.store == nil {
return errNoStore
}
secret, err := g.store.TOTPSecret(ctx, identityID)
if err != nil {
return err
}
counter, ok := g.matchCounter(secret, code)
if !ok {
return ErrInvalidCode
}
if !g.acceptCounter(identityID, counter) {
return ErrInvalidCode // a replay — indistinguishable from a bad code
}
if err := g.store.MarkTOTPVerified(ctx, identityID); err != nil {
return err
}
return nil
}
// matchCounter finds the time step whose code equals code, searching the skew
// window newest-first (L5s: newest-first means the returned counter is the
// highest matching step, so an earlier step is never preferred over a later
// one). The comparison is constant-time.
func (g *Generator) matchCounter(secret []byte, code string) (uint64, bool) {
period := int64(g.opts.Period)
if period <= 0 {
period = 30
}
skew := int64(g.opts.Skew)
base := g.now().UTC().Unix() / period
for d := skew; d >= -skew; d-- {
step := base + d
if step < 0 {
continue
}
want, err := totp.GenerateCodeCustom(string(secret), time.Unix(step*period, 0).UTC(), g.opts)
if err != nil {
continue
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return uint64(step), true
}
}
return 0, false
}
// acceptCounter records counter as the highest accepted for identityID and
// reports whether it is strictly newer than the last one (L5s).
func (g *Generator) acceptCounter(identityID int64, counter uint64) bool {
g.mu.Lock()
defer g.mu.Unlock()
if prev, ok := g.last[identityID]; ok && counter <= prev {
return false
}
g.last[identityID] = counter
return true
}
// forget drops an identity's replay counter (a new secret, L5s).
func (g *Generator) forget(identityID int64) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.last, identityID)
}