NextMyDay/internal/core/auth/totp/totp_test.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

150 lines
4.4 KiB
Go

package totp_test
import (
"context"
"errors"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
)
// mapStore is an in-memory Store. Encryption at rest (A9) is the real store's
// job (proven in the SPEC-006 flow tests); here we pin the TOTP mechanics.
type mapStore struct {
secrets map[int64][]byte
verified map[int64]bool
}
func newMapStore() *mapStore {
return &mapStore{secrets: map[int64][]byte{}, verified: map[int64]bool{}}
}
func (m *mapStore) PutTOTPSecret(_ context.Context, id int64, secret []byte) error {
m.secrets[id] = append([]byte(nil), secret...)
delete(m.verified, id)
return nil
}
func (m *mapStore) TOTPSecret(_ context.Context, id int64) ([]byte, error) {
s, ok := m.secrets[id]
if !ok {
return nil, errors.New("no secret")
}
return s, nil
}
func (m *mapStore) MarkTOTPVerified(_ context.Context, id int64) error {
m.verified[id] = true
return nil
}
func TestBeginIssuesOtpauthURLAndStoresSecret(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
issued, err := svc.Begin(context.Background(), 7, "guid-abc")
if err != nil {
t.Fatalf("Begin: %v", err)
}
if !strings.HasPrefix(issued.URL, "otpauth://totp/") {
t.Errorf("URL = %q, want an otpauth:// URL", issued.URL)
}
secret, ok := st.secrets[7]
if !ok || len(secret) == 0 {
t.Fatal("Begin did not store a secret")
}
if !strings.Contains(issued.URL, string(secret)) {
t.Error("the otpauth URL does not carry the stored secret")
}
}
func TestVerifyAcceptsAValidCodeAndMarksVerified(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
if _, err := svc.Begin(context.Background(), 1, "guid"); err != nil {
t.Fatalf("Begin: %v", err)
}
code, err := pqotp.GenerateCode(string(st.secrets[1]), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if err := svc.Verify(context.Background(), 1, code); err != nil {
t.Fatalf("Verify(valid) = %v, want nil", err)
}
if !st.verified[1] {
t.Error("a valid code did not mark the secret verified")
}
}
func TestVerifyRejectsABadOrEmptyCode(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
if _, err := svc.Begin(context.Background(), 1, "guid"); err != nil {
t.Fatalf("Begin: %v", err)
}
if err := svc.Verify(context.Background(), 1, ""); !errors.Is(err, totp.ErrInvalidCode) {
t.Errorf("Verify(\"\") = %v, want ErrInvalidCode", err)
}
if err := svc.Verify(context.Background(), 1, "000000"); !errors.Is(err, totp.ErrInvalidCode) {
t.Errorf("Verify(bad) = %v, want ErrInvalidCode", err)
}
if st.verified[1] {
t.Error("a bad code marked the secret verified")
}
}
// TestVerifyRejectsAReplay covers L5s: a code that already worked must not work
// again within the skew window.
func TestVerifyRejectsAReplay(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
ctx := context.Background()
if _, err := svc.Begin(ctx, 1, "guid"); err != nil {
t.Fatalf("Begin: %v", err)
}
now := time.Now()
svc.SetClock(func() time.Time { return now })
secret := string(st.secrets[1])
code, err := pqotp.GenerateCode(secret, now)
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if err := svc.Verify(ctx, 1, code); err != nil {
t.Fatalf("first Verify = %v, want nil", err)
}
// The same code, still inside its window, is a replay (L5s).
if err := svc.Verify(ctx, 1, code); !errors.Is(err, totp.ErrInvalidCode) {
t.Errorf("replayed Verify = %v, want ErrInvalidCode (L5s)", err)
}
// A code from an EARLIER step is refused once a newer step was accepted.
newer, err := pqotp.GenerateCode(secret, now.Add(30*time.Second))
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
svc.SetClock(func() time.Time { return now.Add(30 * time.Second) })
if err := svc.Verify(ctx, 1, newer); err != nil {
t.Fatalf("Verify(newer) = %v, want nil", err)
}
svc.SetClock(func() time.Time { return now }) // the skew window still covers step N
if err := svc.Verify(ctx, 1, code); !errors.Is(err, totp.ErrInvalidCode) {
t.Errorf("an older step after a newer one = %v, want ErrInvalidCode (L5s)", err)
}
// A fresh secret resets the counter (re-enrollment, L5s).
if _, err := svc.Begin(ctx, 1, "guid"); err != nil {
t.Fatalf("Begin (re-enroll): %v", err)
}
fresh, err := pqotp.GenerateCode(string(st.secrets[1]), now)
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if err := svc.Verify(ctx, 1, fresh); err != nil {
t.Errorf("Verify after re-enrollment = %v, want nil", err)
}
}