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.
192 lines
6 KiB
Go
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)
|
|
}
|