- 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
123 lines
3.7 KiB
Go
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
|
|
}
|