// 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) }