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