Files
Andy Dixon 89580c0a79 Phase 16: hardening and release 0.1.0
- TOTP second factor (RFC 6238, secret encrypted under the master key),
  ten hashed single-use recovery codes, two-step login with a pending token
  bound to the client address, account enrolment/disable, admin reset
- tests/security: prompt-injection corpus never acts or creates evidence-free
  beliefs; roles and CSRF on form and API paths; cross-mind isolation
  (demo scenario 5); stored-XSS escaping and CSP across pages; secrets never
  echoed (API, pages, database, archives); hostile archive import; session
  lifecycle and security headers
- OpenAPI 3.1 document generated from the route table, served at
  /api/v1/openapi.json and kept complete by a test
- gosec clean (justified #nosec annotations, cookie/redirect hardening,
  bounded multipart parsing, UUID packing without narrowing conversions);
  govulncheck clean with toolchain pinned to go1.26.8
- THREAT_MODEL verification pointers, SECURITY.md testing section, README,
  CHANGELOG 0.1.0, docs/RELEASE_NOTES.md, PLAN status
2026-07-05 22:30:32 +01:00

103 lines
4.1 KiB
Go

package auth
import (
"context"
"errors"
"testing"
"time"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func TestTOTPVectors(t *testing.T) {
// RFC 6238 test vector: secret "12345678901234567890" (base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ), T=59 → 287082 (SHA1, 8 digits 94287082).
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
code, err := TOTPCode(secret, time.Unix(59, 0))
if err != nil || code != "287082" {
t.Fatalf("got %q %v", code, err)
}
if !VerifyTOTP(secret, "287082", time.Unix(59, 0)) || !VerifyTOTP(secret, "287082", time.Unix(59+30, 0)) || VerifyTOTP(secret, "287082", time.Unix(59+120, 0)) {
t.Fatal("verification window wrong")
}
if VerifyTOTP(secret, "28708", time.Unix(59, 0)) || VerifyTOTP(secret, "000000", time.Unix(59, 0)) {
t.Fatal("bad code accepted")
}
if u := OTPAuthURL("Noema", "andy", secret); u != "otpauth://totp/Noema:andy?algorithm=SHA1&digits=6&issuer=Noema&period=30&secret="+secret {
t.Fatal(u)
}
}
func TestTwoStepLoginAndRecovery(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
svc := NewService(pool, time.Hour, 2*time.Hour)
sec, _ := secrets.New("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") // 32 bytes base64
svc.SetSecrets(sec)
u, err := svc.CreateUser(ctx, "andy", "a-long-enough-password", RoleAdministrator)
if err != nil {
t.Fatal(err)
}
// Without a master key enrolment is refused.
if _, _, err := NewService(pool, time.Hour, time.Hour).BeginTOTP(ctx, u.ID, "Noema"); !errors.Is(err, ErrTOTPUnavailable) {
t.Fatalf("expected unavailable, got %v", err)
}
secret, uri, err := svc.BeginTOTP(ctx, u.ID, "Noema")
if err != nil || secret == "" || uri == "" {
t.Fatal(err)
}
// Pending enrolment does not change login yet.
if _, _, err := svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t"); err != nil {
t.Fatalf("login before confirm: %v", err)
}
if _, err := svc.ConfirmTOTP(ctx, u.ID, "000000"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong code accepted: %v", err)
}
code, _ := TOTPCode(secret, time.Now())
codes, err := svc.ConfirmTOTP(ctx, u.ID, code)
if err != nil || len(codes) != 10 {
t.Fatalf("confirm: %v %d", err, len(codes))
}
// Login now stops at the second factor.
_, pendingTok, err := svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t")
if !errors.Is(err, ErrSecondFactorRequired) || pendingTok == "" {
t.Fatalf("expected second factor, got %v", err)
}
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, "000000", "10.0.0.1", "t"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong code completed login: %v", err)
}
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, code, "10.0.0.2", "t"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatal("pending token accepted from another address")
}
sess, tok, usedRecovery, err := svc.CompleteLogin(ctx, pendingTok, code, "10.0.0.1", "t")
if err != nil || tok == "" || usedRecovery || sess.UserID != u.ID {
t.Fatalf("complete: %v", err)
}
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, code, "10.0.0.1", "t"); err == nil {
t.Fatal("pending token reusable")
}
// Recovery code: works once.
_, pendingTok, _ = svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t")
if _, _, used, err := svc.CompleteLogin(ctx, pendingTok, codes[0], "10.0.0.1", "t"); err != nil || !used {
t.Fatalf("recovery: %v", err)
}
if svc.RecoveryCodesLeft(ctx, u.ID) != 9 {
t.Fatal("recovery code not consumed")
}
_, pendingTok, _ = svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t")
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, codes[0], "10.0.0.1", "t"); err == nil {
t.Fatal("recovery code reused")
}
// Disable restores single-step login and wipes codes.
if err := svc.DisableTOTP(ctx, u.ID); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t"); err != nil {
t.Fatalf("login after disable: %v", err)
}
if svc.RecoveryCodesLeft(ctx, u.ID) != 0 {
t.Fatal("codes survived disable")
}
}