From 76a4a6950c0c6bdd3e922caab2a247421b0fa3f3 Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Wed, 14 Oct 2026 10:53:53 +0000 Subject: [PATCH] feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/core/auth/acceptance_test.go | 578 +++++++++++++++ internal/core/auth/auth.go | 381 ++++++++++ internal/core/auth/credentials.go | 151 ++++ internal/core/auth/flow/flow.go | 738 +++++++++++++++++++ internal/core/auth/flow/render.go | 129 ++++ internal/core/auth/identities.go | 285 +++++++ internal/core/auth/sealer.go | 73 ++ internal/core/auth/sealer_test.go | 64 ++ internal/core/auth/sessions.go | 197 +++++ internal/core/auth/totp.go | 99 +++ internal/core/auth/totp/totp.go | 123 ++++ internal/core/auth/totp/totp_test.go | 98 +++ internal/core/auth/webauthn/webauthn.go | 218 ++++++ internal/core/auth/webauthn/webauthn_test.go | 26 + 14 files changed, 3160 insertions(+) create mode 100644 internal/core/auth/acceptance_test.go create mode 100644 internal/core/auth/auth.go create mode 100644 internal/core/auth/credentials.go create mode 100644 internal/core/auth/flow/flow.go create mode 100644 internal/core/auth/flow/render.go create mode 100644 internal/core/auth/identities.go create mode 100644 internal/core/auth/sealer.go create mode 100644 internal/core/auth/sealer_test.go create mode 100644 internal/core/auth/sessions.go create mode 100644 internal/core/auth/totp.go create mode 100644 internal/core/auth/totp/totp.go create mode 100644 internal/core/auth/totp/totp_test.go create mode 100644 internal/core/auth/webauthn/webauthn.go create mode 100644 internal/core/auth/webauthn/webauthn_test.go diff --git a/internal/core/auth/acceptance_test.go b/internal/core/auth/acceptance_test.go new file mode 100644 index 0000000..1e97f7a --- /dev/null +++ b/internal/core/auth/acceptance_test.go @@ -0,0 +1,578 @@ +package auth_test + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// The Identities service satisfies the pure model's injected Resolver +// (SPEC-002 §3.1) — a compile-time proof. +var _ routing.Resolver = (*auth.IdentitiesService)(nil) + +// fakeClock drives session/identity expiry deterministically. +type fakeClock struct{ now time.Time } + +func (c *fakeClock) Now() time.Time { return c.now } +func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) } + +type fixture struct { + t *testing.T + ctx context.Context + db *store.DB + users *users.Store + ids *auth.IdentitiesService + sessions *auth.SessionsService + clock *fakeClock +} + +func newFixture(t *testing.T, opts ...auth.Option) *fixture { + t.Helper() + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + clk := &fakeClock{now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)} + allOpts := append([]auth.Option{auth.WithClock(clk.Now)}, opts...) + aud := audit.New(db.DB) + core := auth.New(db.DB, aud, allOpts...) + + return &fixture{ + t: t, + ctx: context.Background(), + db: db, + users: users.New(db.DB, aud), + ids: core.Identities(), + sessions: core.Sessions(), + clock: clk, + } +} + +func (f *fixture) admin() int64 { + f.t.Helper() + id, err := f.users.Create(f.ctx, 0, "admin@example.com", "Admin", users.RoleAdmin) + mustNoErr(f.t, err) + return id +} + +func (f *fixture) activeIdentity(userID int64) (int64, string) { + f.t.Helper() + id, guid, err := f.ids.Mint(f.ctx, userID) + mustNoErr(f.t, err) + mustNoErr(f.t, f.ids.Activate(f.ctx, id)) + return id, guid +} + +func (f *fixture) auditCount() int { + f.t.Helper() + var n int + if err := f.db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&n); err != nil { + f.t.Fatalf("count audit_log: %v", err) + } + return n +} + +// expectAudit asserts fn produced at least one audit row (AC-7). +func (f *fixture) expectAudit(name string, fn func()) { + f.t.Helper() + before := f.auditCount() + fn() + if after := f.auditCount(); after <= before { + f.t.Errorf("AC-7: %s wrote no audit row (before=%d after=%d)", name, before, after) + } +} + +func mustNoErr(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +// AC-1: two active identities for one user is impossible — service AND index. +func TestAC1_OneActiveIdentityPerUser(t *testing.T) { + f := newFixture(t) + admin := f.admin() + director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector) + mustNoErr(t, err) + + f.activeIdentity(director) + + // Service check: Mint refuses while a live identity exists. + if _, _, err := f.ids.Mint(f.ctx, director); !errors.Is(err, auth.ErrIdentityExists) { + t.Errorf("Mint with a live identity = %v, want ErrIdentityExists", err) + } + + now := store.FormatTime(f.clock.Now()) + + // Index check: a hand-inserted second ACTIVE row is rejected. + if _, err := f.db.Exec( + `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-active', 'active', ?)`, + director, now, + ); err == nil { + t.Error("a second active identity was inserted, want a UNIQUE violation (AC-1 index)") + } + + // Index check via the service: a hand-inserted pending row cannot be + // activated into a second active identity. + res, err := f.db.Exec( + `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-pending', 'pending_enrollment', ?)`, + director, now, + ) + mustNoErr(t, err) + pendingID, err := res.LastInsertId() + mustNoErr(t, err) + if err := f.ids.Activate(f.ctx, pendingID); err == nil { + t.Error("Activate produced a second active identity, want an error (AC-1)") + } +} + +// AC-2: guid is unique and a revoked GUID is never reissued (tombstoned). +func TestAC2_GUIDUniqueAndTombstoned(t *testing.T) { + f := newFixture(t) + admin := f.admin() + id, guid1 := f.activeIdentity(admin) + + if guid1 == "" || routing.IsReservedWorkflow(guid1) { + t.Fatalf("minted guid %q is empty or reserved", guid1) + } + + mustNoErr(t, f.ids.Revoke(f.ctx, id)) + guid2, err := f.ids.Rotate(f.ctx, admin) + mustNoErr(t, err) + if guid2 == guid1 { + t.Error("Rotate reissued the tombstoned GUID (AC-2)") + } + + // The old GUID can never be re-inserted. + if _, err := f.db.Exec( + `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, 'pending_enrollment', ?)`, + admin, guid1, store.FormatTime(f.clock.Now()), + ); err == nil { + t.Error("a tombstoned GUID was reused, want a UNIQUE violation (AC-2)") + } + + // And it resolves as revoked/tombstoned, not as a live identity. + old, err := f.ids.ByGUID(f.ctx, guid1) + mustNoErr(t, err) + if old.Status != auth.StatusRevoked { + t.Errorf("old identity status = %s, want revoked", old.Status) + } + if found, enrolled := f.ids.GUID(guid1); found || enrolled { + t.Errorf("tombstoned GUID resolved found=%v enrolled=%v, want false/false", found, enrolled) + } +} + +// AC-3: an empty session id is rejected (A2). +func TestAC3_EmptySessionRejected(t *testing.T) { + f := newFixture(t) + if _, err := f.sessions.Validate(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) { + t.Errorf("Validate(\"\") = %v, want ErrEmptySessionID (AC-3, A2)", err) + } + if err := f.sessions.Revoke(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) { + t.Errorf("Revoke(\"\") = %v, want ErrEmptySessionID", err) + } +} + +// AC-4: expired/revoked sessions error; RevokeAll kills every session. +func TestAC4_ExpiredRevokedAndRevokeAll(t *testing.T) { + f := newFixture(t, auth.WithSessionTTL(time.Hour)) + admin := f.admin() + id, _ := f.activeIdentity(admin) + + s1, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") + mustNoErr(t, err) + s2, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") + mustNoErr(t, err) + + if _, err := f.sessions.Validate(f.ctx, s1.ID); err != nil { + t.Fatalf("a fresh session failed to validate: %v", err) + } + + // Revoked -> error. + mustNoErr(t, f.sessions.Revoke(f.ctx, s1.ID)) + if _, err := f.sessions.Validate(f.ctx, s1.ID); !errors.Is(err, auth.ErrSessionRevoked) { + t.Errorf("Validate(revoked) = %v, want ErrSessionRevoked", err) + } + + // Expired -> error. + f.clock.Advance(2 * time.Hour) + if _, err := f.sessions.Validate(f.ctx, s2.ID); !errors.Is(err, auth.ErrSessionExpired) { + t.Errorf("Validate(expired) = %v, want ErrSessionExpired", err) + } + + // RevokeAll kills every live session of the identity. + a, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") + mustNoErr(t, err) + b, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") + mustNoErr(t, err) + mustNoErr(t, f.sessions.RevokeAll(f.ctx, id)) + for _, sid := range []string{a.ID, b.ID} { + if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionRevoked) { + t.Errorf("Validate(%s) after RevokeAll = %v, want ErrSessionRevoked", sid, err) + } + } +} + +// AC-5: Rotate revokes the old identity AND its sessions, and mints a new GUID. +func TestAC5_RotateRevokesOldAndMintsNew(t *testing.T) { + f := newFixture(t) + admin := f.admin() + id, guid1 := f.activeIdentity(admin) + session, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") + mustNoErr(t, err) + + guid2, err := f.ids.Rotate(f.ctx, admin) + mustNoErr(t, err) + if guid2 == "" || guid2 == guid1 { + t.Fatalf("Rotate guid = %q, want a fresh non-empty GUID", guid2) + } + + old, err := f.ids.ByGUID(f.ctx, guid1) + mustNoErr(t, err) + if old.Status != auth.StatusRevoked || old.RevokedAt == nil { + t.Errorf("old identity = %+v, want revoked with a timestamp", old) + } + + // Every session of the old identity is dead. + if _, err := f.sessions.Validate(f.ctx, session.ID); !errors.Is(err, auth.ErrSessionRevoked) { + t.Errorf("old session after Rotate = %v, want ErrSessionRevoked", err) + } + + // The new identity is pending enrollment under the same user. + fresh, err := f.ids.ByGUID(f.ctx, guid2) + mustNoErr(t, err) + if fresh.Status != auth.StatusPendingEnrollment { + t.Errorf("new identity status = %s, want pending_enrollment", fresh.Status) + } + if fresh.UserID != admin { + t.Errorf("new identity user = %d, want %d (employment survives rotation)", fresh.UserID, admin) + } +} + +// AC-7: every service mutation writes an audit_log row (A11). +func TestAC7_EveryMutationAudited(t *testing.T) { + f := newFixture(t) + admin := f.admin() + + var director int64 + f.expectAudit("users.Create", func() { + var err error + director, err = f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector) + mustNoErr(t, err) + }) + f.expectAudit("users.SetRole", func() { + mustNoErr(t, f.users.SetRole(f.ctx, director, users.RoleManager)) + }) + + var id int64 + f.expectAudit("identities.Mint", func() { + var err error + id, _, err = f.ids.Mint(f.ctx, director) + mustNoErr(t, err) + }) + f.expectAudit("identities.Activate", func() { + mustNoErr(t, f.ids.Activate(f.ctx, id)) + }) + + var sess auth.Session + f.expectAudit("sessions.Mint", func() { + var err error + sess, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") + mustNoErr(t, err) + }) + f.expectAudit("sessions.Rotate", func() { + _, err := f.sessions.Rotate(f.ctx, sess.ID) + mustNoErr(t, err) + }) + + sess2, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") + mustNoErr(t, err) + f.expectAudit("sessions.Revoke", func() { + mustNoErr(t, f.sessions.Revoke(f.ctx, sess2.ID)) + }) + + _, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") + mustNoErr(t, err) + f.expectAudit("sessions.RevokeAll", func() { + mustNoErr(t, f.sessions.RevokeAll(f.ctx, id)) + }) + + f.expectAudit("identities.Revoke", func() { + mustNoErr(t, f.ids.Revoke(f.ctx, id)) + }) + f.expectAudit("identities.Rotate", func() { + _, err := f.ids.Rotate(f.ctx, director) + mustNoErr(t, err) + }) +} + +// AC-8: the TOTP secret is encrypted at rest and never stored or logged in +// plaintext (A9). +func TestAC8_TOTPSecretEncryptedAndNeverPlaintext(t *testing.T) { + var logs bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(old) }) + + sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x7f}, auth.KeySize)) + mustNoErr(t, err) + + f := newFixture(t, auth.WithSealer(sealer)) + admin := f.admin() + id, _ := f.activeIdentity(admin) + + secret := []byte("JBSWY3DPEHPK3PXP") // the shape of a base32 TOTP secret + f.expectAudit("totp.PutTOTPSecret", func() { + mustNoErr(t, f.ids.PutTOTPSecret(f.ctx, id, secret)) + }) + + // 1) What is stored is ciphertext, not the secret. + var raw []byte + if err := f.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil { + t.Fatalf("read secret_encrypted: %v", err) + } + if bytes.Equal(raw, secret) || bytes.Contains(raw, secret) { + t.Error("AC-8: secret_encrypted holds the plaintext secret (A9)") + } + + // 2) It still decrypts back to the secret. + got, err := f.ids.TOTPSecret(f.ctx, id) + mustNoErr(t, err) + if !bytes.Equal(got, secret) { + t.Errorf("TOTPSecret = %q, want %q", got, secret) + } + + // 3) No audit row carries the secret. + rows, err := f.db.Query( + `SELECT COALESCE(action,''), COALESCE(subject,''), COALESCE(detail,''), COALESCE(correlation_id,'') FROM audit_log`, + ) + mustNoErr(t, err) + defer rows.Close() + for rows.Next() { + var a, s, d, c string + mustNoErr(t, rows.Scan(&a, &s, &d, &c)) + if strings.Contains(a+s+d+c, string(secret)) { + t.Error("AC-8: an audit row contains the TOTP secret (A9)") + } + } + mustNoErr(t, rows.Err()) + + // 4) Nothing reached the log. + if strings.Contains(logs.String(), string(secret)) { + t.Error("AC-8: the TOTP secret reached the log (A9)") + } + + // Verification is audited too and never stores plaintext. + f.expectAudit("totp.MarkTOTPVerified", func() { + mustNoErr(t, f.ids.MarkTOTPVerified(f.ctx, id)) + }) +} + +func TestTOTPWithoutSealerFailsClosed(t *testing.T) { + f := newFixture(t) // no sealer + admin := f.admin() + id, _ := f.activeIdentity(admin) + if err := f.ids.PutTOTPSecret(f.ctx, id, []byte("secret")); !errors.Is(err, auth.ErrNoSealer) { + t.Errorf("PutTOTPSecret without a sealer = %v, want ErrNoSealer (fail closed)", err) + } +} + +// The Identities service satisfies routing.Resolver end to end. +func TestResolverContract(t *testing.T) { + f := newFixture(t) + admin := f.admin() + + if found, enrolled := f.ids.GUID("definitely-unknown"); found || enrolled { + t.Error("unknown GUID reported found") + } + if found, _ := f.ids.GUID(""); found { + t.Error("empty GUID reported found") + } + + id, guid, err := f.ids.Mint(f.ctx, admin) // pending + mustNoErr(t, err) + if found, enrolled := f.ids.GUID(guid); !found || enrolled { + t.Errorf("pending GUID: found=%v enrolled=%v, want true/false", found, enrolled) + } + + mustNoErr(t, f.ids.Activate(f.ctx, id)) + if found, enrolled := f.ids.GUID(guid); !found || !enrolled { + t.Errorf("active GUID: found=%v enrolled=%v, want true/true", found, enrolled) + } +} + +func TestIdentityLifecycleErrors(t *testing.T) { + f := newFixture(t) + admin := f.admin() + + if _, _, err := f.ids.Mint(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) { + t.Errorf("Mint(unknown user) = %v, want ErrUnknownUser", err) + } + if _, err := f.ids.Rotate(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) { + t.Errorf("Rotate(unknown user) = %v, want ErrUnknownUser", err) + } + + id, _, err := f.ids.Mint(f.ctx, admin) + mustNoErr(t, err) + mustNoErr(t, f.ids.Activate(f.ctx, id)) + if err := f.ids.Activate(f.ctx, id); !errors.Is(err, auth.ErrIdentityNotPending) { + t.Errorf("Activate twice = %v, want ErrIdentityNotPending", err) + } + + // A live identity blocks a second Mint. + if _, _, err := f.ids.Mint(f.ctx, admin); !errors.Is(err, auth.ErrIdentityExists) { + t.Errorf("second Mint = %v, want ErrIdentityExists", err) + } + if _, err := f.sessions.Mint(f.ctx, id, "ip", "ua"); err != nil { + t.Fatalf("Mint session for active identity: %v", err) + } +} + +func TestSessionMintRequiresActiveIdentity(t *testing.T) { + f := newFixture(t) + admin := f.admin() + pending, _, err := f.ids.Mint(f.ctx, admin) // still pending + mustNoErr(t, err) + if _, err := f.sessions.Mint(f.ctx, pending, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotActive) { + t.Errorf("Mint for pending identity = %v, want ErrIdentityNotActive", err) + } + if _, err := f.sessions.Mint(f.ctx, 999, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotFound) { + t.Errorf("Mint for unknown identity = %v, want ErrIdentityNotFound", err) + } +} + +func TestSessionGCKeepsValidSessions(t *testing.T) { + f := newFixture(t, auth.WithSessionTTL(time.Hour)) + admin := f.admin() + id, _ := f.activeIdentity(admin) + + stale1, err := f.sessions.Mint(f.ctx, id, "ip", "ua") + mustNoErr(t, err) + stale2, err := f.sessions.Mint(f.ctx, id, "ip", "ua") + mustNoErr(t, err) + + f.clock.Advance(2 * time.Hour) // both stale sessions are now expired + fresh, err := f.sessions.Mint(f.ctx, id, "ip", "ua") + mustNoErr(t, err) + + n, err := f.sessions.GC(f.ctx, f.clock.Now()) + mustNoErr(t, err) + if n != 2 { + t.Errorf("GC removed %d rows, want 2", n) + } + + // The valid session survives — GC never takes the last valid session. + if _, err := f.sessions.Validate(f.ctx, fresh.ID); err != nil { + t.Errorf("GC removed a valid session: %v", err) + } + for _, sid := range []string{stale1.ID, stale2.ID} { + if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionNotFound) { + t.Errorf("stale session %s = %v, want ErrSessionNotFound after GC", sid, err) + } + } +} + +func TestEnrollmentWindow(t *testing.T) { + f := newFixture(t, auth.WithEnrollWindow(72*time.Hour)) + admin := f.admin() + id, _, err := f.ids.Mint(f.ctx, admin) // pending + mustNoErr(t, err) + + ok, err := f.ids.WithinEnrollmentWindow(f.ctx, id) + mustNoErr(t, err) + if !ok { + t.Error("a fresh pending identity is outside its enrollment window") + } + + f.clock.Advance(73 * time.Hour) + if ok, err = f.ids.WithinEnrollmentWindow(f.ctx, id); err != nil { + t.Fatalf("WithinEnrollmentWindow: %v", err) + } + if ok { + t.Error("an identity past its window is still enrollable (A1)") + } +} + +// R-4: the identity services take the ACTOR from the audit.Meta on the context +// (creator/approver) and record the AFFECTED user as the subject — never a +// hard-coded owner. +func TestR4_IdentityAuditActorFromContext(t *testing.T) { + f := newFixture(t) + admin := f.admin() + director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector) + mustNoErr(t, err) + + // The creator acts with an explicit actor on the context (the door would + // attach it); the affected user is the subject. + ctx := audit.WithMeta(f.ctx, audit.Meta{ActorUserID: admin, ClientIP: "203.0.113.9", CorrelationID: "corr-r4"}) + + id, guid, err := f.ids.Mint(ctx, director) + mustNoErr(t, err) + mustNoErr(t, f.ids.Activate(ctx, id)) + mustNoErr(t, f.ids.Revoke(ctx, id)) + + if _, err := f.ids.Rotate(ctx, director); err != nil { + t.Fatalf("Rotate: %v", err) + } + _ = guid + + rows, err := f.db.Query(`SELECT action, actor_user_id, subject, correlation_id FROM audit_log + WHERE action IN ('identity.mint','identity.activate','identity.revoke','identity.rotate') + ORDER BY id`) + if err != nil { + t.Fatalf("query audit_log: %v", err) + } + defer rows.Close() + + want := map[string]bool{ + "identity.mint": true, + "identity.activate": true, + "identity.revoke": true, + "identity.rotate": true, + } + seen := 0 + for rows.Next() { + var action, subject string + var actor sql.NullInt64 + var corr sql.NullString + if err := rows.Scan(&action, &actor, &subject, &corr); err != nil { + t.Fatalf("scan: %v", err) + } + seen++ + if !actor.Valid || actor.Int64 != admin { + t.Errorf("%s actor = %+v, want the context actor %d (R-4)", action, actor, admin) + } + if subject != fmt.Sprintf("user:%d", director) { + t.Errorf("%s subject = %q, want the affected user (R-4)", action, subject) + } + if corr.String != "corr-r4" { + t.Errorf("%s correlation = %q, want corr-r4", action, corr.String) + } + delete(want, action) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + if seen != 4 || len(want) != 0 { + t.Errorf("audited %d identity events, missing %v (R-4)", seen, want) + } +} diff --git a/internal/core/auth/auth.go b/internal/core/auth/auth.go new file mode 100644 index 0000000..313bdf5 --- /dev/null +++ b/internal/core/auth/auth.go @@ -0,0 +1,381 @@ +// Package auth owns MyDay's identities and sessions (PROJECT_PLAN.md §6). +// +// - an IDENTITY is the rotating access record (a GUID + its credentials); +// a USER is the stable person. Credentials hang off an identity, never the +// user (D-008), and a GUID is never recycled (D-008/AC-2). +// - a SESSION is the opaque credential minted after enroll/login; the GUID is +// only a username (D-001 — a bookmark is never a login). +// +// This package also provides the encryption seam for TOTP secrets (A9) — the +// key comes from config later; secrets are never stored or logged in plaintext. +// +// The two service interfaces both name Mint/Rotate/Revoke, so they are separate +// Go types (IdentitiesService, SessionsService) sharing one Core. +// +// Spec: docs/specs/spec-004-identity-sessions.md §3–§6. +package auth + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +// IdentityStatus is an identity's lifecycle state (SPEC-004 §4). +type IdentityStatus string + +const ( + // StatusPendingEnrollment is a freshly minted identity — the invite window + // is open but the identity is a bearer until enrolled (A1). + StatusPendingEnrollment IdentityStatus = "pending_enrollment" + // StatusActive is an enrolled identity; it may authenticate. + StatusActive IdentityStatus = "active" + // StatusRevoked is a tombstoned identity — its GUID is dead forever + // (D-008; a revoked GUID behaves as "not found" to the router). + StatusRevoked IdentityStatus = "revoked" +) + +// Identity is the rotating access record (SPEC-004 §4 `identities`). +type Identity struct { + ID int64 + UserID int64 + GUID string + Status IdentityStatus + CreatedAt time.Time + EnrolledAt *time.Time + RevokedAt *time.Time +} + +// Session is one server-side session row; the id is the cookie value +// (SPEC-004 §4 `sessions`). +type Session struct { + ID string + IdentityID int64 + UserID int64 + CreatedAt time.Time + ExpiresAt time.Time + RevokedAt *time.Time + ClientIP string + UserAgent string +} + +// Identities is the identity service contract (SPEC-004 §5). Per ruling R-1 +// every method takes the request context so audit metadata (actor · client IP · +// correlation) reaches each audit row (A11). +type Identities interface { + // Mint creates a new identity for userID with status pending_enrollment and + // returns its id and a fresh, unguessable GUID. + Mint(ctx context.Context, userID int64) (IdentityID int64, guid string, err error) + + // Activate moves a pending identity to active (the enrollment completes). + Activate(ctx context.Context, id int64) error + + // Rotate performs recovery (D-007): revoke the user's current identity and + // all its sessions, then mint a fresh pending identity — a NEW GUID. + Rotate(ctx context.Context, userID int64) (guid string, err error) + + // Revoke revokes an identity AND every session it minted. + Revoke(ctx context.Context, id int64) error + + // ByGUID resolves a GUID. A missing GUID yields ErrIdentityNotFound. + // NOTE: it is context-taking only for symmetry; a revoked (tombstoned) GUID + // is still returned so the caller can distinguish and audit it (SPEC-004 §8). + ByGUID(ctx context.Context, guid string) (Identity, error) + + // ByUserID returns the user's current (non-revoked) identity — the one the + // door and the /setup wizard follow (SPEC-005 §4: "which step is next?"). + // A user with no live identity yields ErrIdentityNotFound. + ByUserID(ctx context.Context, userID int64) (Identity, error) + + // WithinEnrollmentWindow reports whether a pending identity is still inside + // its bounded invite window (SPEC-004 §4, A1). SPEC-006 refuses enrollment + // outside it. A non-pending identity reports false. + WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error) +} + +// Sessions is the session service contract (SPEC-004 §5/§6). Per ruling R-1 +// every method takes the request context. +type Sessions interface { + // Mint issues a session for an ACTIVE identity. + Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) + + // Validate accepts a session id only if it is non-empty (A2), exists, is + // not expired and is not revoked. + Validate(ctx context.Context, sessionID string) (Session, error) + + // Rotate replaces a valid session with a fresh one (fixation defence). + Rotate(ctx context.Context, sessionID string) (Session, error) + + // Revoke kills one session (logout). + Revoke(ctx context.Context, sessionID string) error + + // RevokeAll kills every live session of an identity (D-007). + RevokeAll(ctx context.Context, identityID int64) error + + // GC deletes expired rows (housekeeping) and returns how many it removed. + // It NEVER deletes a still-valid session (SPEC-004 §6). + GC(ctx context.Context, now time.Time) (int, error) +} + +// Sentinel errors. +var ( + ErrIdentityNotFound = errors.New("auth: identity not found") + ErrIdentityExists = errors.New("auth: the user already has a non-revoked identity") + ErrIdentityNotPending = errors.New("auth: identity is not pending enrollment") + ErrIdentityNotActive = errors.New("auth: identity is not active") + ErrUnknownUser = errors.New("auth: user does not exist") + ErrGUIDExhausted = errors.New("auth: could not allocate a unique GUID") + + ErrEmptySessionID = errors.New("auth: empty session id") // A2 + ErrSessionNotFound = errors.New("auth: session not found") + ErrSessionExpired = errors.New("auth: session expired") + ErrSessionRevoked = errors.New("auth: session revoked") + + ErrNoSealer = errors.New("auth: no TOTP sealer configured") + ErrEmptySecret = errors.New("auth: empty TOTP secret") + ErrSecretNotFound = errors.New("auth: TOTP secret not found") + ErrInvalidCiphertext = errors.New("auth: invalid ciphertext") +) + +// Defaults. These are code defaults to be overridden from config later +// (D-035 keeps settings in the config file). +const ( + // DefaultSessionTTL is how long a minted session lives. + DefaultSessionTTL = 24 * time.Hour + // DefaultEnrollWindow bounds the un-enrolled-GUID bearer window (A1). + DefaultEnrollWindow = 72 * time.Hour +) + +// Core holds the shared dependencies of the identity and session services. +type Core struct { + db *sql.DB + audit audit.Audit + sealer Sealer + now func() time.Time + sessionTTL time.Duration + enrollWindow time.Duration +} + +// Option configures a Core. +type Option func(*Core) + +// WithSessionTTL overrides the session lifetime (default DefaultSessionTTL). +func WithSessionTTL(d time.Duration) Option { + return func(c *Core) { + if d > 0 { + c.sessionTTL = d + } + } +} + +// WithEnrollWindow overrides the enrollment window (default DefaultEnrollWindow). +func WithEnrollWindow(d time.Duration) Option { + return func(c *Core) { + if d > 0 { + c.enrollWindow = d + } + } +} + +// WithSealer supplies the TOTP secret encryption seam (A9). +func WithSealer(sealer Sealer) Option { + return func(c *Core) { c.sealer = sealer } +} + +// WithClock injects a clock (tests). +func WithClock(fn func() time.Time) Option { + return func(c *Core) { + if fn != nil { + c.now = fn + } + } +} + +// New builds the shared Core. aud may be nil (tests) — audit is then skipped. +func New(db *sql.DB, aud audit.Audit, opts ...Option) *Core { + c := &Core{ + db: db, + audit: aud, + now: time.Now, + sessionTTL: DefaultSessionTTL, + enrollWindow: DefaultEnrollWindow, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Identities returns the identity service over this Core. +func (c *Core) Identities() *IdentitiesService { return &IdentitiesService{Core: c} } + +// Sessions returns the session service over this Core. +func (c *Core) Sessions() *SessionsService { return &SessionsService{Core: c} } + +// clock returns the current time, UTC and truncated to the storage precision, +// so comparisons line up exactly with the values written by store.FormatTime. +func (c *Core) clock() time.Time { + return c.now().UTC().Truncate(time.Second) +} + +func (c *Core) record(ctx context.Context, e audit.Event) { + if c.audit != nil { + c.audit.Record(ctx, e) + } +} + +// userExists reports whether the user record exists. +func (c *Core) userExists(userID int64) (bool, error) { + var one int + err := c.db.QueryRow(`SELECT 1 FROM users WHERE user_id = ?`, userID).Scan(&one) + switch { + case errors.Is(err, sql.ErrNoRows): + return false, nil + case err != nil: + return false, fmt.Errorf("auth: user lookup: %w", err) + } + return true, nil +} + +// querier is the subset shared by *sql.DB and *sql.Tx. +type querier interface { + Exec(query string, args ...any) (sql.Result, error) + QueryRow(query string, args ...any) *sql.Row +} + +// newGUID allocates a fresh, unguessable GUID (>=128 bits, D-001): 16 random +// bytes, base64url. It rejects reserved workflow names and any GUID already +// present (identities are never recycled, D-008/AC-2), and retries on +// collision. +func newGUID(q querier) (string, error) { + for attempt := 0; attempt < 8; attempt++ { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("auth: guid entropy: %w", err) + } + guid := base64.RawURLEncoding.EncodeToString(b[:]) + + if err := routing.ValidateWorkflowName(guid); err != nil { + continue // would collide with a reserved level-1 name + } + var one int + err := q.QueryRow(`SELECT 1 FROM identities WHERE guid = ?`, guid).Scan(&one) + switch { + case errors.Is(err, sql.ErrNoRows): + return guid, nil // free + case err != nil: + return "", fmt.Errorf("auth: guid lookup: %w", err) + } + } + return "", ErrGUIDExhausted +} + +// newSessionID returns a 256-bit opaque session id, base64url-encoded. +func newSessionID() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("auth: session entropy: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// rowScanner is satisfied by *sql.Row and *sql.Rows. +type rowScanner interface { + Scan(dest ...any) error +} + +// scanIdentity reads one identity row. +func scanIdentity(row rowScanner) (Identity, error) { + var ( + idn Identity + status string + created string + enrolled sql.NullString + revoked sql.NullString + ) + err := row.Scan(&idn.ID, &idn.UserID, &idn.GUID, &status, &created, &enrolled, &revoked) + if errors.Is(err, sql.ErrNoRows) { + return Identity{}, ErrIdentityNotFound + } + if err != nil { + return Identity{}, fmt.Errorf("auth: scan identity: %w", err) + } + idn.Status = IdentityStatus(status) + if idn.CreatedAt, err = store.ParseTime(created); err != nil { + return Identity{}, fmt.Errorf("auth: parse identity created_at: %w", err) + } + if idn.EnrolledAt, err = parseNullTime(enrolled); err != nil { + return Identity{}, err + } + if idn.RevokedAt, err = parseNullTime(revoked); err != nil { + return Identity{}, err + } + return idn, nil +} + +// scanSession reads one session row. +func scanSession(row rowScanner) (Session, error) { + var ( + sess Session + created string + expires string + revoked sql.NullString + clientIP sql.NullString + userAgent sql.NullString + ) + err := row.Scan(&sess.ID, &sess.IdentityID, &sess.UserID, &created, &expires, &revoked, &clientIP, &userAgent) + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrSessionNotFound + } + if err != nil { + return Session{}, fmt.Errorf("auth: scan session: %w", err) + } + if sess.CreatedAt, err = store.ParseTime(created); err != nil { + return Session{}, fmt.Errorf("auth: parse session created_at: %w", err) + } + if sess.ExpiresAt, err = store.ParseTime(expires); err != nil { + return Session{}, fmt.Errorf("auth: parse session expires_at: %w", err) + } + if sess.RevokedAt, err = parseNullTime(revoked); err != nil { + return Session{}, err + } + sess.ClientIP = clientIP.String + sess.UserAgent = userAgent.String + return sess, nil +} + +func parseNullTime(ns sql.NullString) (*time.Time, error) { + if !ns.Valid || ns.String == "" { + return nil, nil + } + t, err := store.ParseTime(ns.String) + if err != nil { + return nil, fmt.Errorf("auth: parse timestamp: %w", err) + } + return &t, nil +} + +// identityByID loads one identity by primary key. It lives on Core so both the +// identity and session services can use it. +func (c *Core) identityByID(id int64) (Identity, error) { + row := c.db.QueryRow(` + SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at + FROM identities WHERE identity_id = ?`, id) + return scanIdentity(row) +} + +// nullString maps "" to NULL so the trail distinguishes "absent" from "empty". +func nullString(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/internal/core/auth/credentials.go b/internal/core/auth/credentials.go new file mode 100644 index 0000000..252ffd6 --- /dev/null +++ b/internal/core/auth/credentials.go @@ -0,0 +1,151 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +// Credentials is the WebAuthn credential store contract (SPEC-004 §4 +// `webauthn_credentials`). Credentials hang off an IDENTITY, never a user +// (D-008): identity rotation destroys them. +type Credentials interface { + // PutWebAuthnCredential stores a credential for an identity (upsert by + // credential id). + PutWebAuthnCredential(ctx context.Context, identityID int64, c webauthn.Credential) error + + // WebAuthnCredentials returns every credential of an identity. + WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error) + + // UpdateWebAuthnSignCount records an authenticator's advanced counter after + // a successful assertion (clone detection). + UpdateWebAuthnSignCount(ctx context.Context, identityID int64, credentialID []byte, signCount uint32) error + + // HasWebAuthnCredential reports whether an identity has at least one + // registered passkey — the enrollment two-factor check (SPEC-006 §4 AC-2). + HasWebAuthnCredential(ctx context.Context, identityID int64) (bool, error) +} + +var _ Credentials = (*IdentitiesService)(nil) + +// PutWebAuthnCredential implements Credentials. +func (s *IdentitiesService) PutWebAuthnCredential(ctx context.Context, identityID int64, c webauthn.Credential) error { + if len(c.ID) == 0 { + return errors.New("auth: webauthn credential id is empty") + } + if _, err := s.identityByID(identityID); err != nil { + return err + } + if _, err := s.db.Exec( + `INSERT INTO webauthn_credentials + (credential_id, identity_id, public_key, sign_count, transports, aaguid, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(credential_id) DO UPDATE SET + public_key = excluded.public_key, + sign_count = excluded.sign_count, + transports = excluded.transports, + aaguid = excluded.aaguid`, + c.ID, identityID, c.PublicKey, int64(c.SignCount), + strings.Join(c.Transports, ","), nullBytes(c.AAGUID), + store.FormatTime(s.clock()), + ); err != nil { + return fmt.Errorf("auth: store webauthn credential: %w", err) + } + + // No key material in the trail — identifiers only (A9). + s.record(ctx, audit.Event{ + Action: "webauthn.credential.put", + Subject: fmt.Sprintf("identity:%d", identityID), + Detail: fmt.Sprintf("credential_id=%s", fingerprint(c.ID)), + }) + return nil +} + +// WebAuthnCredentials implements Credentials. +func (s *IdentitiesService) WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error) { + rows, err := s.db.Query( + `SELECT credential_id, public_key, sign_count, transports, aaguid + FROM webauthn_credentials WHERE identity_id = ? ORDER BY created_at`, identityID, + ) + if err != nil { + return nil, fmt.Errorf("auth: load webauthn credentials: %w", err) + } + defer rows.Close() + + var out []webauthn.Credential + for rows.Next() { + var ( + c webauthn.Credential + signCount int64 + transports string + aaguid []byte + ) + if err := rows.Scan(&c.ID, &c.PublicKey, &signCount, &transports, &aaguid); err != nil { + return nil, fmt.Errorf("auth: scan webauthn credential: %w", err) + } + c.SignCount = uint32(signCount) + c.AAGUID = aaguid + if transports != "" { + c.Transports = strings.Split(transports, ",") + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("auth: iterate webauthn credentials: %w", err) + } + return out, nil +} + +// UpdateWebAuthnSignCount implements Credentials. It is a no-op when the +// credential is unknown — sign-count tracking is best-effort, never a gate. +func (s *IdentitiesService) UpdateWebAuthnSignCount(ctx context.Context, identityID int64, credentialID []byte, signCount uint32) error { + if _, err := s.db.Exec( + `UPDATE webauthn_credentials SET sign_count = ? + WHERE credential_id = ? AND identity_id = ?`, + int64(signCount), credentialID, identityID, + ); err != nil { + return fmt.Errorf("auth: update sign count: %w", err) + } + return nil +} + +// HasWebAuthnCredential implements Credentials. +func (s *IdentitiesService) HasWebAuthnCredential(ctx context.Context, identityID int64) (bool, error) { + var one int + err := s.db.QueryRow( + `SELECT 1 FROM webauthn_credentials WHERE identity_id = ? LIMIT 1`, identityID, + ).Scan(&one) + switch { + case errors.Is(err, sql.ErrNoRows): + return false, nil + case err != nil: + return false, fmt.Errorf("auth: webauthn credential lookup: %w", err) + } + return true, nil +} + +// nullBytes maps an empty blob to NULL. +func nullBytes(b []byte) any { + if len(b) == 0 { + return nil + } + return b +} + +// fingerprint is a short label for an opaque credential id, for the audit trail +// only — the credential id is public, but the trail keeps just a prefix. +func fingerprint(id []byte) string { + if len(id) == 0 { + return "" + } + if len(id) > 8 { + id = id[:8] + } + return fmt.Sprintf("%x", id) +} diff --git a/internal/core/auth/flow/flow.go b/internal/core/auth/flow/flow.go new file mode 100644 index 0000000..545bf1d --- /dev/null +++ b/internal/core/auth/flow/flow.go @@ -0,0 +1,738 @@ +// Package flow implements SPEC-006: the GUID-driven enroll/login state machine. +// +// GET /{GUID} renders the enrollment screen (pending, in window) or the login +// screen (active, no session); the POST actions run the WebAuthn and TOTP +// ceremonies (`do=auth&action=…`) and mint, rotate or revoke the session. +// +// Two factors only — Passkey (primary) + TOTP (fallback) (D-005). Enrollment +// completes ONLY when BOTH a passkey credential AND a verified TOTP code are +// present (AC-2); outside the bounded window it is refused (A1/AC-4). The +// /setup wizard is the one exception (D-041/TOTPOnlyEnroll): it enrolls TOTP +// only, because a loopback passkey is unusable at the public domain — a nudged +// TOTP login later adds one (§8). +// +// The package holds no HTTP routing and no storage of its own: it drives the +// core auth services and renders the two screens. The door verifies the +// pre-session CSRF token before invoking an action (A15). +// +// Spec: docs/specs/spec-006-enroll-login.md. +package flow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// CeremonyCookieName carries the signed WebAuthn session data between begin and +// finish (no server-side table needed). +const CeremonyCookieName = "myday_webauthn" + +// Deps assembles the flow's dependencies. +type Deps struct { + // Identities resolves GUIDs, activates identities and checks the window. + Identities auth.Identities + // Credentials stores the WebAuthn credentials. + Credentials auth.Credentials + // Sessions mints/rotates/revokes the credential. + Sessions auth.Sessions + // Users loads the display name for the WebAuthn user entity. + Users users.Users + // WebAuthn is the ceremony seam (fake in tests). + WebAuthn webauthn.Service + // TOTP issues and verifies the second factor. + TOTP totp.Service + // CSRF issues/verifies the pre-session double-submit token and lends its + // signer for the ceremony cookie. + CSRF *httpx.PreSessionCSRF + // Audit receives the enroll/login/logout events (optional). + Audit audit.Audit + // SuccessPath, when set, overrides where a successful enroll/login lands + // (default "/{guid}/home"). SPEC-005's wizard injects "/setup" so the + // bootstrap stays on the wizard until it is finished. + SuccessPath func(guid string) string + // TOTPOnlyEnroll activates a PENDING identity on a verified TOTP code + // ALONE (D-041). The /setup wizard sets it: a passkey registered over + // plain-HTTP loopback binds RP ID 127.0.0.1 and is unusable at the public + // domain (O-13). Default false keeps SPEC-006 §4's mandatory two factors. + TOTPOnlyEnroll bool + // SuppressNudge, when non-nil and true, disables the passkey nudge + // (SPEC-006 §8). The door wires it to setup mode, where a passkey is + // useless. nil = the nudge runs (subject to the other conditions). + SuppressNudge func() bool + // Secure marks cookies Secure (https). + Secure bool + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger + // Now is the clock (tests). + Now func() time.Time +} + +// Actions is the SPEC-006 flow engine. +type Actions struct { + d Deps +} + +// New validates and builds the flow. +func New(d Deps) (*Actions, error) { + switch { + case d.Identities == nil: + return nil, errors.New("flow: Identities is required") + case d.Credentials == nil: + return nil, errors.New("flow: Credentials is required") + case d.Sessions == nil: + return nil, errors.New("flow: Sessions is required") + case d.WebAuthn == nil: + return nil, errors.New("flow: WebAuthn is required") + case d.TOTP == nil: + return nil, errors.New("flow: TOTP is required") + case d.CSRF == nil: + return nil, errors.New("flow: CSRF is required") + } + if d.Logger == nil { + d.Logger = slog.Default() + } + if d.Now == nil { + d.Now = time.Now + } + return &Actions{d: d}, nil +} + +// ── screens ───────────────────────────────────────────────────────────────── + +// EnrollScreen renders the enrollment screen for a pending GUID (SPEC-006 §4). +func (a *Actions) EnrollScreen(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, ok, err := a.pendingInWindow(ctx, guid) + if err != nil { + return err + } + if !ok { + // Unknown, revoked, already active or past the window. An already-active + // identity is sent to the app; everything else is a generic 404 (A1). + if idn.Status == auth.StatusActive { + http.Redirect(w, r, "/"+guid+"/home", http.StatusFound) + return nil + } + writeError(w, http.StatusNotFound, "not found") + return nil + } + token, err := a.d.CSRF.Ensure(w, r) + if err != nil { + return err + } + return renderHTML(w, http.StatusOK, enrollPage(guid, token)) +} + +// LoginScreen renders the login screen for an active GUID with no session +// (SPEC-006 §5). It answers 200 — a rendered page is a successful response; +// only a FAILED credential attempt is 401/400 (F-1, SPEC-005 §7). +func (a *Actions) LoginScreen(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolve(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + if idn.Status != auth.StatusActive { + writeError(w, http.StatusNotFound, "not found") + return nil + } + token, err := a.d.CSRF.Ensure(w, r) + if err != nil { + return err + } + return renderHTML(w, http.StatusOK, loginPage(guid, token)) +} + +// ── enrollment actions ────────────────────────────────────────────────────── + +// EnrollBegin starts the WebAuthn registration ceremony (SPEC-006 §4). +func (a *Actions) EnrollBegin(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, ok, err := a.pendingInWindow(ctx, guid) + if err != nil { + return err + } + if !ok { + writeError(w, http.StatusNotFound, "not found") + return nil + } + creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) + if err != nil { + return err + } + begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds) + if err != nil { + // A ceremony error is a client-side problem, not a server fault. + a.d.Logger.Warn("flow: begin registration failed", "error", err) + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + if err := a.putCeremony(w, begin); err != nil { + return err + } + return writeJSON(w, http.StatusOK, begin.Options) +} + +// EnrollFinish completes the WebAuthn registration and stores the credential. +// It does NOT enroll the identity — TOTP must still follow (AC-2). +func (a *Actions) EnrollFinish(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, ok, err := a.pendingInWindow(ctx, guid) + if err != nil { + return err + } + if !ok { + writeError(w, http.StatusNotFound, "not found") + return nil + } + begin, err := a.takeCeremony(w, r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r) + if err != nil { + a.d.Logger.Warn("flow: finish registration failed", "error", err) + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil { + return err + } + a.audit(ctx, "enroll.passkey", idn, "passkey registered") + return writeJSON(w, http.StatusOK, []byte(`{"status":"credential-stored"}`)) +} + +// TOTPBegin issues (and encrypts) the TOTP secret and returns the otpauth URL. +func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, ok, err := a.pendingInWindow(ctx, guid) + if err != nil { + return err + } + if !ok { + writeError(w, http.StatusNotFound, "not found") + return nil + } + issued, err := a.d.TOTP.Begin(ctx, idn.ID, idn.GUID) + if err != nil { + return err + } + a.audit(ctx, "enroll.totp.begin", idn, "secret issued") + payload, err := json.Marshal(map[string]string{"otpauth_url": issued.URL}) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, payload) +} + +// TOTPVerify verifies a code. For a PENDING identity it completes enrollment — +// but ONLY when a passkey credential already exists (two-factor, AC-2), unless +// TOTPOnlyEnroll is set (the /setup wizard, D-041). For an ACTIVE identity it +// is the login fallback (SPEC-006 §5) and may trigger the passkey nudge (§8). +func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolve(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + code := r.FormValue("code") + + switch idn.Status { + case auth.StatusActive: + // Login fallback (Passkey is primary; this is device-loss recovery). + if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil { + a.failAuth(w) + return nil + } + a.audit(ctx, "login.totp", idn, "totp") + // The passkey nudge (SPEC-006 §8): a TOTP login with no passkey, on a + // non-loopback origin, is nudged to add one. A TOTP login WITH a + // passkey is never nudged; nor is a passkey login (login.finish). + target := "" + if a.shouldNudge(ctx, r, idn) { + target = nudgePath(idn.GUID) + } + return a.establishSessionAt(w, r, idn, target) + + case auth.StatusPendingEnrollment: + inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID) + if err != nil { + return err + } + if !inWindow { + writeError(w, http.StatusNotFound, "not found") + return nil + } + // Both factors are mandatory for a normal enrollment (AC-2). The + // /setup wizard is the exception: it enrolls TOTP only (D-041), since + // a loopback-enrolled passkey is unusable at the public domain. + detail := "passkey+totp" + if a.d.TOTPOnlyEnroll { + detail = "totp" + } else { + hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID) + if err != nil { + return err + } + if !hasPasskey { + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + } + if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil { + a.failAuth(w) + return nil + } + if err := a.d.Identities.Activate(ctx, idn.ID); err != nil { + return err + } + a.audit(ctx, "enroll.complete", idn, detail) + return a.establishSession(w, r, idn) + + default: // revoked — tombstoned + writeError(w, http.StatusNotFound, "not found") + return nil + } +} + +// ── login actions ─────────────────────────────────────────────────────────── + +// LoginBegin starts the WebAuthn assertion ceremony (SPEC-006 §5). +func (a *Actions) LoginBegin(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolveActive(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) + if err != nil { + return err + } + if len(creds) == 0 { + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + begin, err := a.d.WebAuthn.BeginLogin(ctx, a.webauthnUser(ctx, idn), creds) + if err != nil { + a.d.Logger.Warn("flow: begin login failed", "error", err) + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + if err := a.putCeremony(w, begin); err != nil { + return err + } + return writeJSON(w, http.StatusOK, begin.Options) +} + +// LoginFinish verifies the assertion and mints (or rotates) the session. +func (a *Actions) LoginFinish(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolveActive(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + begin, err := a.takeCeremony(w, r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) + if err != nil { + return err + } + cred, err := a.d.WebAuthn.FinishLogin(ctx, a.webauthnUser(ctx, idn), begin, r, creds) + if err != nil { + a.d.Logger.Warn("flow: finish login failed", "error", err) + a.failAuth(w) + return nil + } + if err := a.d.Credentials.UpdateWebAuthnSignCount(ctx, idn.ID, cred.ID, cred.SignCount); err != nil { + return err + } + a.audit(ctx, "login.passkey", idn, "passkey") + return a.establishSession(w, r, idn) +} + +// Logout revokes the current session and clears the cookie (SPEC-006 §5). +func (a *Actions) Logout(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" { + if err := a.d.Sessions.Revoke(ctx, c.Value); err != nil { + a.d.Logger.Warn("flow: logout revoke failed", "error", err) + } + } + httpx.ClearSessionCookie(w, httpx.SessionCookieName, a.d.Secure) + http.Redirect(w, r, "/home", http.StatusSeeOther) + return nil +} + +// ── internals ─────────────────────────────────────────────────────────────── + +// resolve loads the identity for a GUID. A missing or tombstoned GUID reports +// auth.ErrIdentityNotFound (mapped to 404); any other error is a real fault and +// is propagated (500). +func (a *Actions) resolve(ctx context.Context, guid string) (auth.Identity, error) { + if guid == "" { + return auth.Identity{}, auth.ErrIdentityNotFound + } + idn, err := a.d.Identities.ByGUID(ctx, guid) + if err != nil { + return auth.Identity{}, err + } + if idn.Status == auth.StatusRevoked { + return auth.Identity{}, auth.ErrIdentityNotFound + } + return idn, nil +} + +// resolveActive resolves a GUID that must map to an ACTIVE identity. +func (a *Actions) resolveActive(ctx context.Context, guid string) (auth.Identity, error) { + idn, err := a.resolve(ctx, guid) + if err != nil { + return auth.Identity{}, err + } + if idn.Status != auth.StatusActive { + return auth.Identity{}, auth.ErrIdentityNotFound + } + return idn, nil +} + +// pendingInWindow resolves a GUID that must map to a pending identity still +// inside its bounded enrollment window (A1). The returned identity is also +// present when ok=false so the caller can distinguish "already active". +func (a *Actions) pendingInWindow(ctx context.Context, guid string) (auth.Identity, bool, error) { + idn, err := a.resolve(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + return auth.Identity{}, false, nil + } + return auth.Identity{}, false, err + } + if idn.Status != auth.StatusPendingEnrollment { + return idn, false, nil + } + inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID) + if err != nil { + return auth.Identity{}, false, err + } + return idn, inWindow, nil +} + +// webauthnUser builds the ceremony user for an identity. +func (a *Actions) webauthnUser(ctx context.Context, idn auth.Identity) webauthn.User { + display := idn.GUID + if a.d.Users != nil { + if u, err := a.d.Users.Get(ctx, idn.UserID); err == nil && u.DisplayName != "" { + display = u.DisplayName + } + } + return webauthn.User{ + ID: handleFor(idn.UserID), + Name: idn.GUID, + DisplayName: display, + } +} + +// handleFor derives the WebAuthn user handle from the stable user id. +func handleFor(userID int64) []byte { + return []byte(fmt.Sprintf("myday-user-%d", userID)) +} + +// establishSession mints a session on success, rotating a live one when the +// request already carries a valid session for the same identity (fixation +// defence, SPEC-006 §5), then redirects to the app. +func (a *Actions) establishSession(w http.ResponseWriter, r *http.Request, idn auth.Identity) error { + return a.establishSessionAt(w, r, idn, "") +} + +// establishSessionAt is establishSession with an optional explicit landing +// path (the passkey nudge overrides the default success target, SPEC-006 §8). +func (a *Actions) establishSessionAt(w http.ResponseWriter, r *http.Request, idn auth.Identity, target string) error { + sess, err := a.issueSession(r, idn) + if err != nil { + return err + } + httpx.SetSessionCookie(w, httpx.SessionCookieName, sess.ID, a.d.Secure) + if target == "" { + target = a.successTarget(idn.GUID) + } + http.Redirect(w, r, target, http.StatusSeeOther) + return nil +} + +// successTarget is where a successful flow lands: the configured SuccessPath +// (the wizard's "/setup") or the user's dashboard. +func (a *Actions) successTarget(guid string) string { + if a.d.SuccessPath != nil { + if p := a.d.SuccessPath(guid); p != "" { + return p + } + } + return "/" + guid + "/home" +} + +// nudgePath is where a TOTP login lands when the passkey nudge applies +// (SPEC-006 §8): the session-gated passkey screen carried by the `auth` module. +func nudgePath(guid string) string { return "/" + guid + "/auth/passkey" } + +// shouldNudge reports whether the passkey nudge applies to a TOTP login +// (SPEC-006 §8): the identity has NO passkey, the user has not opted out, the +// origin is NOT loopback, and the nudge is not suppressed (setup mode). Any +// lookup error fails safe — no nudge. +func (a *Actions) shouldNudge(ctx context.Context, r *http.Request, idn auth.Identity) bool { + if a.d.SuppressNudge != nil && a.d.SuppressNudge() { + return false + } + if httpx.IsLoopbackHost(r.Host) { + return false + } + if a.d.Users == nil { + return false + } + optOut, err := a.d.Users.PasskeyOptOut(ctx, idn.UserID) + if err != nil { + a.d.Logger.Warn("flow: passkey opt-out lookup failed", "error", err) + return false + } + if optOut { + return false + } + hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID) + if err != nil { + a.d.Logger.Warn("flow: credential lookup failed", "error", err) + return false + } + return !hasPasskey +} + +// ── the passkey nudge (SPEC-006 §8, D-041) ────────────────────────────────── + +// PasskeyPrompt renders the passkey nudge screen. It is a session-gated ROUTE +// on the `auth` module (GET /{GUID}/auth/passkey) — the redirect target of a +// nudged TOTP login. It BEGINS the WebAuthn registration ceremony and embeds +// the options for the browser, so the single `passkey.add` action completes it. +// +// The door has already validated the session, so the forms carry the +// SESSION-bound CSRF token (SPEC-003 §6) — A15 does not apply. +func (a *Actions) PasskeyPrompt(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolveActive(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) + if err != nil { + return err + } + begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds) + if err != nil { + a.d.Logger.Warn("flow: begin passkey registration failed", "error", err) + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + if err := a.putCeremony(w, begin); err != nil { + return err + } + return renderHTML(w, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options))) +} + +// PasskeyAdd completes the WebAuthn registration begun by PasskeyPrompt and +// stores the credential (audited, AC-11). It is session-gated by the door. +func (a *Actions) PasskeyAdd(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolveActive(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + begin, err := a.takeCeremony(w, r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r) + if err != nil { + a.d.Logger.Warn("flow: finish passkey registration failed", "error", err) + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil { + return err + } + a.audit(ctx, "passkey.add", idn, "passkey registered from the login nudge") + http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther) + return nil +} + +// PasskeyLater skips the nudge this time. Nothing is persisted (SPEC-006 §8) — +// the user is nudged again at the next TOTP login. +func (a *Actions) PasskeyLater(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolveActive(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther) + return nil +} + +// PasskeyNever records the persistent opt-out (users.passkey_opt_out, D-041). +// The write is audited by the users service (A11); later TOTP logins never +// nudge again. The marker survives identity rotation. +func (a *Actions) PasskeyNever(w http.ResponseWriter, r *http.Request, guid string) error { + ctx := r.Context() + idn, err := a.resolveActive(ctx, guid) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + writeError(w, http.StatusNotFound, "not found") + return nil + } + return err + } + if a.d.Users == nil { + return errors.New("flow: Users is required for the passkey opt-out") + } + if err := a.d.Users.SetPasskeyOptOut(ctx, idn.UserID, true); err != nil { + return err + } + http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther) + return nil +} + +// issueSession rotates a matching live session, else mints a fresh one. +func (a *Actions) issueSession(r *http.Request, idn auth.Identity) (auth.Session, error) { + ctx := r.Context() + var ip string + if m, ok := audit.MetaFrom(ctx); ok { + ip = m.ClientIP + } + if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" { + if old, err := a.d.Sessions.Validate(ctx, c.Value); err == nil && old.IdentityID == idn.ID { + return a.d.Sessions.Rotate(ctx, c.Value) + } + } + return a.d.Sessions.Mint(ctx, idn.ID, ip, r.UserAgent()) +} + +// putCeremony signs the WebAuthn state and stores it in a short-lived cookie. +func (a *Actions) putCeremony(w http.ResponseWriter, b *webauthn.Begin) error { + raw, err := json.Marshal(b) + if err != nil { + return fmt.Errorf("flow: marshal ceremony: %w", err) + } + http.SetCookie(w, &http.Cookie{ + Name: CeremonyCookieName, + Value: a.d.CSRF.Signer().Sign(string(raw)), + Path: "/", + HttpOnly: true, + Secure: a.d.Secure, + SameSite: http.SameSiteLaxMode, + MaxAge: 300, + }) + return nil +} + +// takeCeremony reads and clears the ceremony cookie. +func (a *Actions) takeCeremony(w http.ResponseWriter, r *http.Request) (*webauthn.Begin, error) { + c, err := r.Cookie(CeremonyCookieName) + if err != nil || c.Value == "" { + return nil, errors.New("flow: missing ceremony state") + } + raw, ok := a.d.CSRF.Signer().Unsign(c.Value) + if !ok { + return nil, errors.New("flow: invalid ceremony state") + } + var b webauthn.Begin + if err := json.Unmarshal([]byte(raw), &b); err != nil { + return nil, fmt.Errorf("flow: decode ceremony: %w", err) + } + // Single use. + http.SetCookie(w, &http.Cookie{ + Name: CeremonyCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: a.d.Secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + return &b, nil +} + +// audit records a flow event. The actor is the affected user (they proved it); +// the identity is the subject. +func (a *Actions) audit(ctx context.Context, action string, idn auth.Identity, detail string) { + if a.d.Audit == nil { + return + } + a.d.Audit.Record(ctx, audit.Event{ + ActorUserID: idn.UserID, + Action: action, + Subject: fmt.Sprintf("identity:%d", idn.ID), + Detail: detail, + }) +} + +// failAuth answers with a terse, generic 401 — never revealing which factor +// failed, whether the identity exists, or why (A3). +func (a *Actions) failAuth(w http.ResponseWriter) { + writeError(w, http.StatusUnauthorized, "unauthorized") +} + +// writeError writes a terse plain-text error. +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = w.Write([]byte(msg + "\n")) +} + +// writeJSON writes a JSON body. Every ceremony response is `no-store` (F-2): +// the TOTP response embeds the otpauth URL, which contains the secret. +func writeJSON(w http.ResponseWriter, status int, body []byte) error { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, err := w.Write(body) + return err +} diff --git a/internal/core/auth/flow/render.go b/internal/core/auth/flow/render.go new file mode 100644 index 0000000..ad90348 --- /dev/null +++ b/internal/core/auth/flow/render.go @@ -0,0 +1,129 @@ +package flow + +import ( + "html" + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// renderHTML writes a server-rendered page inside the core shell (SPEC-009 §4). +// The door renders no dynamic HTML itself (SPEC-003 §4); these are the flow's +// hand-off targets. +// +// F-2: the enrollment screen carries the TOTP ceremony and the login screen a +// session — neither may be cached, so every flow response is `no-store`. +func renderHTML(w http.ResponseWriter, status int, page web.Page) error { + w.Header().Set("Cache-Control", "no-store") + return web.Render(w, status, page) +} + +// enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is +// driven by the same-origin WebAuthn client (SPEC-009 §5) from the data-* +// contract on
; the TOTP forms work without JavaScript. Every form +// carries the signed pre-session CSRF token (A15), which the client also reads +// from the shell's . +func enrollPage(guid, token string) web.Page { + g := html.EscapeString(guid) + c := html.EscapeString(token) + action := func(name string) string { + return `"/` + g + `?do=auth&action=` + name + `"` + } + return web.Page{ + Title: "Enrollment", + GUID: guid, + Guest: true, + CSRF: token, + MainAttrs: web.Attrs(`data-webauthn="enroll"` + + ` data-begin=` + action("enroll.begin") + + ` data-finish=` + action("enroll.finish")), + Content: web.Markup(`

Enrollment

+

Two factors are required: a passkey and an authenticator app.

+
+

1. Passkey

+

Register a passkey for this device.

+ + +
+
+

2. Authenticator app

+
+ +
+
+ + +
+
`), + } +} + +// passkeyPage is the post-login passkey nudge (SPEC-006 §8, D-041). The +// WebAuthn registration is begun server-side and the options are embedded for +// the browser; the single `passkey.add` action completes it. The two skip forms +// work without JavaScript. Every form carries the SESSION-bound CSRF token (a +// session exists here — A15 does not apply). +func passkeyPage(guid, token, options string) web.Page { + g := html.EscapeString(guid) + c := html.EscapeString(token) + action := func(name string) string { + return `"/` + g + `?do=auth&action=` + name + `"` + } + return web.Page{ + Title: "Add a passkey", + GUID: guid, + CSRF: token, + Flash: "Skip the one-time code next time by adding a passkey for this device.", + MainAttrs: web.Attrs(`data-webauthn="passkey"` + + ` data-finish=` + action("passkey.add") + + ` data-options="` + html.EscapeString(options) + `"`), + Content: web.Markup(`

Add a passkey

+
+

Use this device's screen lock or security key.

+ + +
+
+

Not now?

+
+ +
+
+ +
+
`), + } +} + +// loginPage is the login screen (SPEC-006 §5): Passkey primary, TOTP fallback. +func loginPage(guid, token string) web.Page { + g := html.EscapeString(guid) + c := html.EscapeString(token) + action := func(name string) string { + return `"/` + g + `?do=auth&action=` + name + `"` + } + return web.Page{ + Title: "Sign in", + GUID: guid, + Guest: true, + CSRF: token, + MainAttrs: web.Attrs(`data-webauthn="login"` + + ` data-begin=` + action("login.begin") + + ` data-finish=` + action("login.finish")), + Content: web.Markup(`

Sign in

+

Use a passkey for this device, or your authenticator code.

+
+

Passkey

+

Sign in with a passkey.

+ + +
+
+

Authenticator code

+
+ + +
+
`), + } +} diff --git a/internal/core/auth/identities.go b/internal/core/auth/identities.go new file mode 100644 index 0000000..6b734e7 --- /dev/null +++ b/internal/core/auth/identities.go @@ -0,0 +1,285 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +// IdentitiesService implements Identities (and the routing.Resolver contract). +type IdentitiesService struct { + *Core +} + +var ( + _ Identities = (*IdentitiesService)(nil) +) + +// Mint implements Identities.Mint (SPEC-004 §5). +// +// A user has at most one non-revoked identity at a time, so Mint refuses if one +// still exists — recovery goes through Rotate, which retires the old one first +// (D-007). +func (s *IdentitiesService) Mint(ctx context.Context, userID int64) (int64, string, error) { + ok, err := s.userExists(userID) + if err != nil { + return 0, "", err + } + if !ok { + return 0, "", fmt.Errorf("%w: user:%d", ErrUnknownUser, userID) + } + + var live int + if err := s.db.QueryRow( + `SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, userID, + ).Scan(&live); err != nil { + return 0, "", fmt.Errorf("auth: count live identities: %w", err) + } + if live > 0 { + return 0, "", fmt.Errorf("%w: user:%d", ErrIdentityExists, userID) + } + + guid, err := newGUID(s.db) + if err != nil { + return 0, "", err + } + + res, err := s.db.Exec( + `INSERT INTO identities (user_id, guid, status, created_at) + VALUES (?, ?, ?, ?)`, + userID, guid, string(StatusPendingEnrollment), store.FormatTime(s.clock()), + ) + if err != nil { + return 0, "", fmt.Errorf("auth: insert identity: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, "", fmt.Errorf("auth: identity id: %w", err) + } + + // The ACTOR is the creator/approver and comes from the audit.Meta on ctx + // (ruling R-4); the affected user is the SUBJECT. The event leaves + // ActorUserID unset so audit.Merge fills it from the context. + s.record(ctx, audit.Event{ + Action: "identity.mint", + Subject: fmt.Sprintf("user:%d", userID), + Detail: fmt.Sprintf("identity:%d status=pending_enrollment", id), + }) + return id, guid, nil +} + +// Activate implements Identities.Activate: pending_enrollment -> active. +func (s *IdentitiesService) Activate(ctx context.Context, id int64) error { + idn, err := s.identityByID(id) + if err != nil { + return err + } + if idn.Status != StatusPendingEnrollment { + return fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotPending, id, idn.Status) + } + + now := store.FormatTime(s.clock()) + if _, err := s.db.Exec( + `UPDATE identities SET status = ?, enrolled_at = ? WHERE identity_id = ?`, + string(StatusActive), now, id, + ); err != nil { + // The partial unique index rejects a second active identity (AC-1). + return fmt.Errorf("auth: activate identity: %w", err) + } + + // The actor is the context actor (R-4); the affected user is the subject. + s.record(ctx, audit.Event{ + Action: "identity.activate", + Subject: fmt.Sprintf("user:%d", idn.UserID), + Detail: fmt.Sprintf("identity:%d status=active", id), + }) + return nil +} + +// Rotate implements Identities.Rotate (D-007, AC-5): revoke the current +// identity AND all its sessions, then mint a fresh pending identity with a NEW +// GUID. The old GUID is tombstoned forever. +func (s *IdentitiesService) Rotate(ctx context.Context, userID int64) (string, error) { + ok, err := s.userExists(userID) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("%w: user:%d", ErrUnknownUser, userID) + } + + now := store.FormatTime(s.clock()) + + tx, err := s.db.Begin() + if err != nil { + return "", fmt.Errorf("auth: begin rotate: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Retire the current identity, if any. + var oldID int64 + err = tx.QueryRow( + `SELECT identity_id FROM identities + WHERE user_id = ? AND status <> 'revoked' + ORDER BY identity_id DESC LIMIT 1`, userID, + ).Scan(&oldID) + switch { + case errors.Is(err, sql.ErrNoRows): + // nothing to revoke + case err != nil: + return "", fmt.Errorf("auth: find current identity: %w", err) + default: + if _, err := tx.Exec( + `UPDATE identities SET status = ?, revoked_at = ? WHERE identity_id = ?`, + string(StatusRevoked), now, oldID, + ); err != nil { + return "", fmt.Errorf("auth: revoke old identity: %w", err) + } + if _, err := tx.Exec( + `UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`, + now, oldID, + ); err != nil { + return "", fmt.Errorf("auth: revoke old sessions: %w", err) + } + } + + // Mint the replacement. + guid, err := newGUID(tx) + if err != nil { + return "", err + } + if _, err := tx.Exec( + `INSERT INTO identities (user_id, guid, status, created_at) + VALUES (?, ?, ?, ?)`, + userID, guid, string(StatusPendingEnrollment), now, + ); err != nil { + return "", fmt.Errorf("auth: insert rotated identity: %w", err) + } + + if err := tx.Commit(); err != nil { + return "", fmt.Errorf("auth: commit rotate: %w", err) + } + + s.record(ctx, audit.Event{ + Action: "identity.rotate", + Subject: fmt.Sprintf("user:%d", userID), + Detail: "old identity revoked; new GUID minted", + }) + return guid, nil +} + +// Revoke implements Identities.Revoke: revoke the identity and every session +// it minted. +func (s *IdentitiesService) Revoke(ctx context.Context, id int64) error { + idn, err := s.identityByID(id) + if err != nil { + return err + } + if idn.Status == StatusRevoked { + return nil // idempotent + } + + now := store.FormatTime(s.clock()) + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("auth: begin revoke: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec( + `UPDATE identities SET status = ?, revoked_at = ? WHERE identity_id = ?`, + string(StatusRevoked), now, id, + ); err != nil { + return fmt.Errorf("auth: revoke identity: %w", err) + } + if _, err := tx.Exec( + `UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`, + now, id, + ); err != nil { + return fmt.Errorf("auth: revoke sessions: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("auth: commit revoke: %w", err) + } + + s.record(ctx, audit.Event{ + Action: "identity.revoke", + Subject: fmt.Sprintf("user:%d", idn.UserID), + Detail: fmt.Sprintf("identity:%d status=revoked", id), + }) + return nil +} + +// ByGUID implements Identities.ByGUID. Unlike a revoked GUID — which is +// tombstoned and looks like "not found" to the door — a revoked identity is +// still returned here so callers can distinguish and audit it. +// +// It takes a context for symmetry with the rest of the service contract +// (ruling R-1); the lookup itself is read-only. +func (s *IdentitiesService) ByGUID(ctx context.Context, guid string) (Identity, error) { + return s.byGUID(guid) +} + +// byGUID is the context-free read behind ByGUID and the routing.Resolver +// contract (which cannot take a context — it is part of the pure model, +// SPEC-002 §3.1). +func (s *IdentitiesService) byGUID(guid string) (Identity, error) { + if guid == "" { + return Identity{}, ErrIdentityNotFound + } + row := s.db.QueryRow(` + SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at + FROM identities WHERE guid = ?`, guid) + return scanIdentity(row) +} + +// ByUserID returns the user's current (non-revoked) identity — the newest row +// that is not a tombstone. It is the wizard's lookup (SPEC-005 §4): the step is +// derived from the admin's identity status. Revoked rows stay as tombstones +// (D-008) and are never selected. +func (s *IdentitiesService) ByUserID(ctx context.Context, userID int64) (Identity, error) { + row := s.db.QueryRow(` + SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at + FROM identities + WHERE user_id = ? AND status <> 'revoked' + ORDER BY identity_id DESC LIMIT 1`, userID) + return scanIdentity(row) +} + +// GUID implements the routing.Resolver contract (SPEC-002 §3.1): it answers +// found/enrolled for the pure model. A revoked (tombstoned) GUID is reported as +// not found so the door returns 404 (SPEC-006 §3). +// +// It deliberately takes NO context: it is an implementation of the pure +// model's injected interface and never writes audit. +func (s *IdentitiesService) GUID(guid string) (found, enrolled bool) { + idn, err := s.byGUID(guid) + if err != nil { + return false, false + } + switch idn.Status { + case StatusActive: + return true, true + case StatusPendingEnrollment: + return true, false + default: // revoked + return false, false + } +} + +// WithinEnrollmentWindow reports whether a pending identity is still inside its +// bounded invite window (SPEC-004 §4, A1). A non-pending identity is false. +func (s *IdentitiesService) WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error) { + idn, err := s.identityByID(id) + if err != nil { + return false, err + } + if idn.Status != StatusPendingEnrollment { + return false, nil + } + return idn.CreatedAt.Add(s.enrollWindow).After(s.clock()), nil +} diff --git a/internal/core/auth/sealer.go b/internal/core/auth/sealer.go new file mode 100644 index 0000000..4440152 --- /dev/null +++ b/internal/core/auth/sealer.go @@ -0,0 +1,73 @@ +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" +) + +// Sealer is the encryption seam for TOTP secrets at rest (A9). The key comes +// from config later (D-035); this package only defines the mechanism. +// +// A Sealer must return ciphertext that never equals — and never contains — the +// plaintext. +type Sealer interface { + Seal(plaintext []byte) ([]byte, error) + Open(ciphertext []byte) ([]byte, error) +} + +// KeySize is the AES-256 key length required by AEADSealer. +const KeySize = 32 + +// AEADSealer encrypts with AES-256-GCM. The stored blob is +// +// nonce || ciphertext || tag +// +// A fresh random nonce is generated on every Seal, so sealing the same secret +// twice yields different ciphertext. +type AEADSealer struct { + aead cipher.AEAD +} + +// NewAEADSealer builds a Sealer from a 32-byte key. +func NewAEADSealer(key []byte) (*AEADSealer, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("auth: TOTP encryption key must be %d bytes, got %d", KeySize, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("auth: new cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("auth: new GCM: %w", err) + } + return &AEADSealer{aead: gcm}, nil +} + +// Seal encrypts plaintext. +func (s *AEADSealer) Seal(plaintext []byte) ([]byte, error) { + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("auth: seal nonce: %w", err) + } + // Seal appends to nonce, producing nonce || ciphertext||tag. + return s.aead.Seal(nonce, nonce, plaintext, nil), nil +} + +// Open decrypts a blob produced by Seal. +func (s *AEADSealer) Open(ciphertext []byte) ([]byte, error) { + ns := s.aead.NonceSize() + if len(ciphertext) < ns { + return nil, ErrInvalidCiphertext + } + nonce, ct := ciphertext[:ns], ciphertext[ns:] + plaintext, err := s.aead.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidCiphertext, err) + } + return plaintext, nil +} + +var _ Sealer = (*AEADSealer)(nil) diff --git a/internal/core/auth/sealer_test.go b/internal/core/auth/sealer_test.go new file mode 100644 index 0000000..cba8079 --- /dev/null +++ b/internal/core/auth/sealer_test.go @@ -0,0 +1,64 @@ +package auth_test + +import ( + "bytes" + "errors" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" +) + +func TestAEADSealerRoundTrip(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, auth.KeySize) + sealer, err := auth.NewAEADSealer(key) + if err != nil { + t.Fatalf("NewAEADSealer: %v", err) + } + + plaintext := []byte("JBSWY3DPEHPK3PXP") + sealed, err := sealer.Seal(plaintext) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if bytes.Contains(sealed, plaintext) { + t.Fatal("ciphertext contains the plaintext") + } + + opened, err := sealer.Open(sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(opened, plaintext) { + t.Errorf("Open = %q, want %q", opened, plaintext) + } + + // A fresh nonce means sealing twice yields different ciphertext. + sealed2, err := sealer.Seal(plaintext) + if err != nil { + t.Fatalf("Seal (2): %v", err) + } + if bytes.Equal(sealed, sealed2) { + t.Error("two seals of the same plaintext are identical (nonce not fresh)") + } +} + +func TestAEADSealerRejectsWrongKeySize(t *testing.T) { + if _, err := auth.NewAEADSealer(make([]byte, 16)); err == nil { + t.Error("NewAEADSealer(16 bytes) = nil error, want error") + } +} + +func TestAEADSealerRejectsTamperedCiphertext(t *testing.T) { + sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{1}, auth.KeySize)) + if err != nil { + t.Fatalf("NewAEADSealer: %v", err) + } + sealed, err := sealer.Seal([]byte("secret")) + if err != nil { + t.Fatalf("Seal: %v", err) + } + sealed[len(sealed)-1] ^= 0xff // flip a tag bit + if _, err := sealer.Open(sealed); !errors.Is(err, auth.ErrInvalidCiphertext) { + t.Errorf("Open(tampered) = %v, want ErrInvalidCiphertext", err) + } +} diff --git a/internal/core/auth/sessions.go b/internal/core/auth/sessions.go new file mode 100644 index 0000000..f19b771 --- /dev/null +++ b/internal/core/auth/sessions.go @@ -0,0 +1,197 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +// SessionsService implements Sessions. +type SessionsService struct { + *Core +} + +var _ Sessions = (*SessionsService)(nil) + +// Mint implements Sessions.Mint (SPEC-004 §6): issue a session for an ACTIVE +// identity. A pending or revoked identity gets no session. +func (s *SessionsService) Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) { + idn, err := s.identityByID(identityID) + if err != nil { + return Session{}, err + } + if idn.Status != StatusActive { + return Session{}, fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotActive, identityID, idn.Status) + } + + sid, err := newSessionID() + if err != nil { + return Session{}, err + } + now := s.clock() + sess := Session{ + ID: sid, + IdentityID: identityID, + UserID: idn.UserID, + CreatedAt: now, + ExpiresAt: now.Add(s.sessionTTL), + ClientIP: ip, + UserAgent: ua, + } + + if _, err := s.db.Exec( + `INSERT INTO sessions + (session_id, identity_id, user_id, created_at, expires_at, client_ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + sess.ID, sess.IdentityID, sess.UserID, + store.FormatTime(sess.CreatedAt), store.FormatTime(sess.ExpiresAt), + nullString(ip), nullString(ua), + ); err != nil { + return Session{}, fmt.Errorf("auth: insert session: %w", err) + } + + s.record(ctx, audit.Event{ + ActorUserID: idn.UserID, + Action: "session.mint", + Subject: fmt.Sprintf("identity:%d", identityID), + Detail: "expires_at=" + store.FormatTime(sess.ExpiresAt), + ClientIP: ip, + }) + return sess, nil +} + +// Validate implements Sessions.Validate (A2). An empty id is rejected outright; +// a missing, revoked or expired session is an error. +func (s *SessionsService) Validate(ctx context.Context, sessionID string) (Session, error) { + if sessionID == "" { + return Session{}, ErrEmptySessionID + } + row := s.db.QueryRow(` + SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent + FROM sessions WHERE session_id = ?`, sessionID) + sess, err := scanSession(row) + if err != nil { + return Session{}, err + } + if sess.RevokedAt != nil { + return Session{}, fmt.Errorf("%w: %s", ErrSessionRevoked, sessionID) + } + if !sess.ExpiresAt.After(s.clock()) { + return Session{}, fmt.Errorf("%w: %s", ErrSessionExpired, sessionID) + } + return sess, nil +} + +// Rotate implements Sessions.Rotate (fixation defence, SPEC-006 §5): the old +// session is revoked and a fresh one issued for the same identity. +func (s *SessionsService) Rotate(ctx context.Context, sessionID string) (Session, error) { + old, err := s.Validate(ctx, sessionID) + if err != nil { + return Session{}, err + } + + if _, err := s.db.Exec( + `UPDATE sessions SET revoked_at = ? WHERE session_id = ? AND revoked_at IS NULL`, + store.FormatTime(s.clock()), sessionID, + ); err != nil { + return Session{}, fmt.Errorf("auth: revoke on rotate: %w", err) + } + + sess, err := s.Mint(ctx, old.IdentityID, old.ClientIP, old.UserAgent) + if err != nil { + return Session{}, err + } + s.record(ctx, audit.Event{ + ActorUserID: old.UserID, + Action: "session.rotate", + Subject: fmt.Sprintf("identity:%d", old.IdentityID), + Detail: "session rotated on login", + ClientIP: old.ClientIP, + }) + return sess, nil +} + +// Revoke implements Sessions.Revoke (logout). It is idempotent for a session +// that is already revoked. +func (s *SessionsService) Revoke(ctx context.Context, sessionID string) error { + if sessionID == "" { + return ErrEmptySessionID + } + sess, err := s.lookupSession(sessionID) + if err != nil { + return err + } + if sess.RevokedAt != nil { + return nil + } + if _, err := s.db.Exec( + `UPDATE sessions SET revoked_at = ? WHERE session_id = ?`, + store.FormatTime(s.clock()), sessionID, + ); err != nil { + return fmt.Errorf("auth: revoke session: %w", err) + } + s.record(ctx, audit.Event{ + ActorUserID: sess.UserID, + Action: "session.revoke", + Subject: fmt.Sprintf("identity:%d", sess.IdentityID), + Detail: "logout", + ClientIP: sess.ClientIP, + }) + return nil +} + +// RevokeAll implements Sessions.RevokeAll (D-007): kill every live session of +// an identity. +func (s *SessionsService) RevokeAll(ctx context.Context, identityID int64) error { + res, err := s.db.Exec( + `UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`, + store.FormatTime(s.clock()), identityID, + ) + if err != nil { + return fmt.Errorf("auth: revoke all: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return nil + } + s.record(ctx, audit.Event{ + Action: "session.revoke_all", + Subject: fmt.Sprintf("identity:%d", identityID), + Detail: fmt.Sprintf("revoked=%d", n), + }) + return nil +} + +// GC implements Sessions.GC (SPEC-004 §6): delete expired rows only. It never +// removes a still-valid session — "never the principal's last valid session". +func (s *SessionsService) GC(ctx context.Context, now time.Time) (int, error) { + res, err := s.db.Exec( + `DELETE FROM sessions WHERE expires_at <= ?`, + store.FormatTime(now), + ) + if err != nil { + return 0, fmt.Errorf("auth: gc: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("auth: gc rows affected: %w", err) + } + return int(n), nil +} + +// lookupSession loads a session by id without validating it (used by Revoke, +// which must tolerate a revoked row). +func (s *SessionsService) lookupSession(sessionID string) (Session, error) { + row := s.db.QueryRow(` + SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent + FROM sessions WHERE session_id = ?`, sessionID) + sess, err := scanSession(row) + if errors.Is(err, ErrSessionNotFound) { + return Session{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) + } + return sess, err +} diff --git a/internal/core/auth/totp.go b/internal/core/auth/totp.go new file mode 100644 index 0000000..9cb76ad --- /dev/null +++ b/internal/core/auth/totp.go @@ -0,0 +1,99 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +// The TOTP secret store (SPEC-004 §4 `totp_secrets`, A9). +// +// The plaintext secret exists only transiently in memory while being sealed or +// opened; the database only ever holds ciphertext, and no secret material ever +// reaches an audit row, a log line, or an error string. + +// PutTOTPSecret seals secret and stores the ciphertext for an identity, +// replacing any previous secret and clearing its verified mark. +func (s *IdentitiesService) PutTOTPSecret(ctx context.Context, identityID int64, secret []byte) error { + if s.sealer == nil { + // Fail closed: never fall back to storing plaintext (A9). + return ErrNoSealer + } + if len(secret) == 0 { + return ErrEmptySecret + } + if _, err := s.identityByID(identityID); err != nil { + return err + } + + encrypted, err := s.sealer.Seal(secret) + if err != nil { + return fmt.Errorf("auth: seal totp secret: %w", err) + } + + if _, err := s.db.Exec( + `INSERT INTO totp_secrets (identity_id, secret_encrypted, verified_at) + VALUES (?, ?, NULL) + ON CONFLICT(identity_id) DO UPDATE SET + secret_encrypted = excluded.secret_encrypted, + verified_at = NULL`, + identityID, encrypted, + ); err != nil { + return fmt.Errorf("auth: store totp secret: %w", err) + } + + // NOTE: the secret is deliberately absent from this event (A9). + s.record(ctx, audit.Event{ + Action: "totp.secret.put", + Subject: fmt.Sprintf("identity:%d", identityID), + Detail: "secret stored encrypted", + }) + return nil +} + +// TOTPSecret returns the decrypted secret for an identity. +func (s *IdentitiesService) TOTPSecret(ctx context.Context, identityID int64) ([]byte, error) { + if s.sealer == nil { + return nil, ErrNoSealer + } + var encrypted []byte + err := s.db.QueryRow( + `SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, identityID, + ).Scan(&encrypted) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: identity:%d", ErrSecretNotFound, identityID) + } + if err != nil { + return nil, fmt.Errorf("auth: load totp secret: %w", err) + } + plaintext, err := s.sealer.Open(encrypted) + if err != nil { + return nil, fmt.Errorf("auth: open totp secret: %w", err) + } + return plaintext, nil +} + +// MarkTOTPVerified records that a TOTP code has been verified for an identity. +func (s *IdentitiesService) MarkTOTPVerified(ctx context.Context, identityID int64) error { + res, err := s.db.Exec( + `UPDATE totp_secrets SET verified_at = ? WHERE identity_id = ?`, + store.FormatTime(s.clock()), identityID, + ) + if err != nil { + return fmt.Errorf("auth: mark totp verified: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("%w: identity:%d", ErrSecretNotFound, identityID) + } + s.record(ctx, audit.Event{ + Action: "totp.verified", + Subject: fmt.Sprintf("identity:%d", identityID), + Detail: "verified_at set", + }) + return nil +} diff --git a/internal/core/auth/totp/totp.go b/internal/core/auth/totp/totp.go new file mode 100644 index 0000000..19a10ba --- /dev/null +++ b/internal/core/auth/totp/totp.go @@ -0,0 +1,123 @@ +// 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 +} diff --git a/internal/core/auth/totp/totp_test.go b/internal/core/auth/totp/totp_test.go new file mode 100644 index 0000000..00973a7 --- /dev/null +++ b/internal/core/auth/totp/totp_test.go @@ -0,0 +1,98 @@ +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 TestVerifyRejectsBadOrEmptyCode(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") + } +} diff --git a/internal/core/auth/webauthn/webauthn.go b/internal/core/auth/webauthn/webauthn.go new file mode 100644 index 0000000..0a99f65 --- /dev/null +++ b/internal/core/auth/webauthn/webauthn.go @@ -0,0 +1,218 @@ +// Package webauthn wraps github.com/go-webauthn/webauthn behind a small +// interface so the enroll/login flows (SPEC-006) can be exercised without a +// browser. The ceremony is browser-bound, so tests inject a fake Service. +// +// The package persists nothing: it turns a begin/response pair into a +// Credential the caller stores (SPEC-004 §4 `webauthn_credentials`), keeping +// the storage decision in the auth service. +// +// Spec: docs/specs/spec-006-enroll-login.md §4/§5. +package webauthn + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-webauthn/webauthn/protocol" + lib "github.com/go-webauthn/webauthn/webauthn" +) + +// Credential is the persisted WebAuthn credential (SPEC-004 §4). It is the +// subset of the library's Credential the core stores. +type Credential struct { + // ID is the credential id (primary key). + ID []byte + // PublicKey is the COSE-encoded credential public key. + PublicKey []byte + // SignCount is the authenticator's signature counter (clone detection). + SignCount uint32 + // Transports are the authenticator transport hints, comma-joined at rest. + Transports []string + // AAGUID identifies the authenticator model (may be empty). + AAGUID []byte +} + +// User is the Relying Party's user record for a ceremony. ID is the WebAuthn +// user handle (<= 64 bytes) — it must be stable for the life of the identity. +type User struct { + ID []byte + Name string + DisplayName string +} + +// Begin pairs the browser options (JSON) with the server-side session data the +// finish step must carry. The flow serializes it into a signed cookie between +// the two calls. +type Begin struct { + // Options is the JSON for navigator.credentials.create/get. + Options json.RawMessage + // SessionData is the opaque server state (challenge, expiry, …). + SessionData []byte +} + +// Service is WebAuthn behind an interface (browser-bound, so tests inject a +// fake). Finish* parse the response from r's body. +type Service interface { + BeginRegistration(ctx context.Context, u User, existing []Credential) (*Begin, error) + FinishRegistration(ctx context.Context, u User, b *Begin, r *http.Request) (Credential, error) + + BeginLogin(ctx context.Context, u User, creds []Credential) (*Begin, error) + FinishLogin(ctx context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error) +} + +// Config is the Relying Party configuration (derived from server.base_url). +type Config struct { + // RPID is the relying party id — the origin's host without scheme/port. + RPID string + // RPDisplayName is a human-readable name. + RPDisplayName string + // RPOrigins are the permitted origins, fully qualified. + RPOrigins []string +} + +// impl is the real Service backed by go-webauthn. +type impl struct { + wa *lib.WebAuthn +} + +// New builds the real WebAuthn service. It fails closed when the configuration +// is invalid (no origin, malformed RPID). +func New(cfg Config) (Service, error) { + if cfg.RPDisplayName == "" { + cfg.RPDisplayName = "MyDay" + } + wa, err := lib.New(&lib.Config{ + RPID: cfg.RPID, + RPDisplayName: cfg.RPDisplayName, + RPOrigins: cfg.RPOrigins, + }) + if err != nil { + return nil, fmt.Errorf("webauthn: config: %w", err) + } + return &impl{wa: wa}, nil +} + +var _ Service = (*impl)(nil) + +// BeginRegistration implements Service. +func (w *impl) BeginRegistration(_ context.Context, u User, existing []Credential) (*Begin, error) { + creation, session, err := w.wa.BeginRegistration(buildUser(u, existing)) + if err != nil { + return nil, fmt.Errorf("webauthn: begin registration: %w", err) + } + return newBegin(creation, session) +} + +// FinishRegistration implements Service. +func (w *impl) FinishRegistration(_ context.Context, u User, b *Begin, r *http.Request) (Credential, error) { + session, err := decodeSession(b) + if err != nil { + return Credential{}, err + } + cred, err := w.wa.FinishRegistration(buildUser(u, nil), session, r) + if err != nil { + return Credential{}, fmt.Errorf("webauthn: finish registration: %w", err) + } + return fromLib(cred), nil +} + +// BeginLogin implements Service. +func (w *impl) BeginLogin(_ context.Context, u User, creds []Credential) (*Begin, error) { + assertion, session, err := w.wa.BeginLogin(buildUser(u, creds)) + if err != nil { + return nil, fmt.Errorf("webauthn: begin login: %w", err) + } + return newBegin(assertion, session) +} + +// FinishLogin implements Service. +func (w *impl) FinishLogin(_ context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error) { + session, err := decodeSession(b) + if err != nil { + return Credential{}, err + } + cred, err := w.wa.FinishLogin(buildUser(u, creds), session, r) + if err != nil { + return Credential{}, fmt.Errorf("webauthn: finish login: %w", err) + } + return fromLib(cred), nil +} + +// newBegin marshals the options and the session data into a Begin. +func newBegin(options any, session *lib.SessionData) (*Begin, error) { + opts, err := json.Marshal(options) + if err != nil { + return nil, fmt.Errorf("webauthn: marshal options: %w", err) + } + sd, err := json.Marshal(session) + if err != nil { + return nil, fmt.Errorf("webauthn: marshal session: %w", err) + } + return &Begin{Options: opts, SessionData: sd}, nil +} + +// decodeSession restores the library session data from a Begin. +func decodeSession(b *Begin) (lib.SessionData, error) { + if b == nil || len(b.SessionData) == 0 { + return lib.SessionData{}, fmt.Errorf("webauthn: missing ceremony state") + } + var session lib.SessionData + if err := json.Unmarshal(b.SessionData, &session); err != nil { + return lib.SessionData{}, fmt.Errorf("webauthn: decode session: %w", err) + } + return session, nil +} + +// fromLib maps a library credential to the persisted subset. +func fromLib(c *lib.Credential) Credential { + if c == nil { + return Credential{} + } + transports := make([]string, 0, len(c.Transport)) + for _, t := range c.Transport { + transports = append(transports, string(t)) + } + return Credential{ + ID: c.ID, + PublicKey: c.PublicKey, + SignCount: c.Authenticator.SignCount, + Transports: transports, + AAGUID: c.Authenticator.AAGUID, + } +} + +// libUser adapts our User to the library's User interface. +type libUser struct { + id []byte + name string + displayName string + creds []lib.Credential +} + +func (u libUser) WebAuthnID() []byte { return u.id } +func (u libUser) WebAuthnName() string { return u.name } +func (u libUser) WebAuthnDisplayName() string { return u.displayName } +func (u libUser) WebAuthnCredentials() []lib.Credential { return u.creds } + +// buildUser adapts a User plus its stored credentials for the library. +func buildUser(u User, creds []Credential) libUser { + libCreds := make([]lib.Credential, 0, len(creds)) + for _, c := range creds { + transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transports)) + for _, t := range c.Transports { + transports = append(transports, protocol.AuthenticatorTransport(t)) + } + libCreds = append(libCreds, lib.Credential{ + ID: c.ID, + PublicKey: c.PublicKey, + Transport: transports, + Authenticator: lib.Authenticator{ + AAGUID: c.AAGUID, + SignCount: c.SignCount, + }, + }) + } + return libUser{id: u.ID, name: u.Name, displayName: u.DisplayName, creds: libCreds} +} diff --git a/internal/core/auth/webauthn/webauthn_test.go b/internal/core/auth/webauthn/webauthn_test.go new file mode 100644 index 0000000..ac5dc0f --- /dev/null +++ b/internal/core/auth/webauthn/webauthn_test.go @@ -0,0 +1,26 @@ +package webauthn_test + +import ( + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" +) + +func TestNewValidatesConfig(t *testing.T) { + svc, err := webauthn.New(webauthn.Config{ + RPID: "myday.example.com", + RPDisplayName: "MyDay", + RPOrigins: []string{"https://myday.example.com"}, + }) + if err != nil { + t.Fatalf("New(valid) = %v, want nil", err) + } + if svc == nil { + t.Fatal("New returned a nil service") + } + + // No origin -> fail closed. + if _, err := webauthn.New(webauthn.Config{RPID: "myday.example.com"}); err == nil { + t.Error("New(no origins) = nil, want an error") + } +}