diff --git a/README.md b/README.md index 9d6ee8f..e876089 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,38 @@ tokens, err := cryden.CompleteLoginWithRecoveryCode(ctx, engine, secondFactor.Pe **One safety property worth knowing:** `"recovery_code"` only ever appears in `Login`'s `Methods` list *alongside* `"totp"` and/or `"webauthn"` — never on its own. If an account's last real second factor gets disabled while unconsumed codes still exist in storage, those codes stop being offered at all, rather than silently becoming a standalone permanent backdoor into the account. Calling either function without `Config.RecoveryCodes` set returns `cryden.ErrRecoveryCodesNotConfigured`. +## Breached-password check + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields... + BreachedPasswordChecker: yourChecker, // implements security.BreachedPasswordChecker +}) +``` + +Ships **zero implementations** — checking a password against a breach database means an outbound network call (e.g. to [HIBP's Pwned Passwords API](https://haveibeenpwned.com/API/v3#PwnedPasswords), which uses k-anonymity so you never send the actual password), and the engine doesn't talk to the internet on its own initiative anywhere else in this codebase, so it doesn't start here either. A minimal HIBP implementation looks roughly like: + +```go +type hibpChecker struct{ client *http.Client } + +func (h *hibpChecker) IsBreached(ctx context.Context, password string) (bool, error) { + sum := sha1.Sum([]byte(password)) + hash := strings.ToUpper(hex.EncodeToString(sum[:])) + prefix, suffix := hash[:5], hash[5:] + + req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.pwnedpasswords.com/range/"+prefix, nil) + resp, err := h.client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return strings.Contains(string(body), suffix), nil +} +``` + +Checked on `SignUp` and `ChangePassword`, after the password policy (cheap, local checks first) and after `ChangePassword`'s current-password verification (a new password's breach status should never leak to someone who hasn't already proven they own the account). **A checker error fails open** — SignUp/ChangePassword proceed rather than blocking on a third-party API's uptime; only a confirmed breach (`true, nil`) rejects the password with `auth.ErrPasswordBreached`. + ## AI-assisted admin queries (library support only) The `ai` subpackage provides the safety machinery for natural-language admin tooling — an allowlisted `QueryIntent` type, `validateIntent`, and `ExecuteQuery` — plus `store/postgres.SafeQueryStore`, a read-only query executor. This is a foundation for tools like `csax`'s CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to `SafeQueryStore` must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. `ai.LLMProvider` ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model). @@ -287,6 +319,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too - Two-factor authentication: TOTP and passkeys (WebAuthn), unified under one pause state — see [Two-factor authentication](#two-factor-authentication-totp) and [Passkeys](#passkeys-webauthn-as-a-second-factor) - Magic-link (passwordless) login for existing accounts, routed through the same second-factor gate — see [Magic-link login](#magic-link-passwordless-login) - Recovery (backup) codes as a second-factor fallback, with a safety guard against becoming a standalone backdoor once the real factor is removed — see [Recovery codes](#recovery-backup-codes) +- Breached-password checking (interface-only, bring your own HIBP/etc.) — see [Breached-password check](#breached-password-check) - JWT access tokens + rotating opaque refresh tokens with theft/reuse detection - Session listing and revocation - Change password (requires current password, revokes all other sessions) diff --git a/auth/account_test.go b/auth/account_test.go index 09ef667..86d3ab1 100644 --- a/auth/account_test.go +++ b/auth/account_test.go @@ -21,7 +21,7 @@ func TestChangePassword_Success(t *testing.T) { users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) sessions.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "user-1"}) - err := ChangePassword(ctx, users, sessions, hasher, audit, log, "user-1", "old-password", "new-password") + err := ChangePassword(ctx, users, sessions, hasher, nil, audit, log, "user-1", "old-password", "new-password") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -49,7 +49,7 @@ func TestChangePassword_RejectsWrongCurrentPassword(t *testing.T) { hash, _ := hasher.Hash("old-password") users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) - err := ChangePassword(ctx, users, sessions, hasher, audit, log, "user-1", "totally-wrong", "new-password") + err := ChangePassword(ctx, users, sessions, hasher, nil, audit, log, "user-1", "totally-wrong", "new-password") if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials, got %v", err) } diff --git a/auth/breach_test.go b/auth/breach_test.go new file mode 100644 index 0000000..9c8373d --- /dev/null +++ b/auth/breach_test.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" +) + +// fakeBreachChecker is a controllable test double — reports a fixed +// result (or a fixed error, simulating the check service itself being +// unreachable) and records how many times it was called. +type fakeBreachChecker struct { + breached bool + err error + calls int +} + +func (f *fakeBreachChecker) IsBreached(ctx context.Context, password string) (bool, error) { + f.calls++ + return f.breached, f.err +} + +func TestSignUp_RejectsBreachedPassword(t *testing.T) { + users, audit, log, hasher, ids, limiter := newTestDeps() + ctx := context.Background() + checker := &fakeBreachChecker{breached: true} + + _, err := SignUp(ctx, users, hasher, ids, limiter, checker, audit, log, "proguy@example.com", "password123", "1.2.3.4") + if err != ErrPasswordBreached { + t.Errorf("expected ErrPasswordBreached, got %v", err) + } + if checker.calls != 1 { + t.Errorf("expected the checker to be called exactly once, got %d", checker.calls) + } +} + +func TestSignUp_BreachCheckerErrorFailsOpen(t *testing.T) { + users, audit, log, hasher, ids, limiter := newTestDeps() + ctx := context.Background() + checker := &fakeBreachChecker{err: errors.New("simulated HIBP outage")} + + _, err := SignUp(ctx, users, hasher, ids, limiter, checker, audit, log, "proguy@example.com", "password123", "1.2.3.4") + if err != nil { + t.Fatalf("expected signup to succeed (fail open) when the breach checker errors, got %v", err) + } +} + +func TestChangePassword_RejectsBreachedNewPassword(t *testing.T) { + users := memory.NewUserStore() + sessions := memory.NewSessionStore() + audit := memory.NewAuditStore() + hasher, _ := security.NewBcryptHasher(4) + log := testLogger{} + ctx := context.Background() + checker := &fakeBreachChecker{breached: true} + + hash, _ := hasher.Hash("old-password") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + err := ChangePassword(ctx, users, sessions, hasher, checker, audit, log, "user-1", "old-password", "password123") + if err != ErrPasswordBreached { + t.Errorf("expected ErrPasswordBreached, got %v", err) + } +} + +func TestChangePassword_WrongCurrentPasswordCheckedBeforeBreach(t *testing.T) { + // Breach status about a NEW password should never leak to someone + // who hasn't already proven they own the account. + users := memory.NewUserStore() + sessions := memory.NewSessionStore() + audit := memory.NewAuditStore() + hasher, _ := security.NewBcryptHasher(4) + log := testLogger{} + ctx := context.Background() + checker := &fakeBreachChecker{breached: true} + + hash, _ := hasher.Hash("old-password") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + err := ChangePassword(ctx, users, sessions, hasher, checker, audit, log, "user-1", "totally-wrong", "password123") + if err != ErrInvalidCredentials { + t.Errorf("expected ErrInvalidCredentials (checked before breach check), got %v", err) + } + if checker.calls != 0 { + t.Errorf("expected the breach checker to never be called before current-password verification, got %d calls", checker.calls) + } +} + +func TestSignUp_BreachRejectionIsAudited(t *testing.T) { + users, audit, log, hasher, ids, limiter := newTestDeps() + ctx := context.Background() + checker := &fakeBreachChecker{breached: true} + + SignUp(ctx, users, hasher, ids, limiter, checker, audit, log, "proguy@example.com", "password123", "1.2.3.4") + + events, err := audit.SearchByType(ctx, store.EventPasswordBreachRejected, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Errorf("expected exactly 1 password_breach_rejected event, got %d", len(events)) + } +} diff --git a/auth/errors.go b/auth/errors.go index 27ad4f4..248bc23 100644 --- a/auth/errors.go +++ b/auth/errors.go @@ -103,4 +103,8 @@ var ( // state handed back to FinishRegisterPasskey/CompleteLoginWithWebAuthn // fails to decrypt or has been tampered with. ErrInvalidCeremonyToken = errors.New("auth: passkey ceremony expired or invalid, please try again") + // ErrPasswordBreached is returned by SignUp/ChangePassword when a + // configured BreachedPasswordChecker confirms the password has + // appeared in a known data breach. + ErrPasswordBreached = errors.New("auth: this password has appeared in a known data breach and cannot be used") ) diff --git a/auth/password.go b/auth/password.go index ab8b311..f589d97 100644 --- a/auth/password.go +++ b/auth/password.go @@ -13,9 +13,11 @@ import ( // from just a valid access token alone, since a stolen access token // would then be enough to lock the real owner out permanently. // -// NOTE: run your ValidatePassword policy check on newPassword BEFORE -// calling this — same as SignUp, fail on bad input before touching -// the DB or spending bcrypt's CPU cost. +// newPassword is checked against known breaches if breachChecker is +// set — same enforcement and same fail-open-on-checker-error behavior +// as SignUp; see its doc comment. Checked AFTER the current-password +// verification, so a caller can't use this to probe breach status +// without already proving they own the account. // // On success, ALL sessions are revoked (including the one making this // request) — if the old password leaked, any session an attacker @@ -26,6 +28,7 @@ func ChangePassword( users store.UserStore, sessions store.SessionStore, hasher security.Hasher, + breachChecker security.BreachedPasswordChecker, audit store.AuditStore, log logger.Logger, userID string, @@ -42,6 +45,21 @@ func ChangePassword( return ErrInvalidCredentials } + if breachChecker != nil { + breached, err := breachChecker.IsBreached(ctx, newPassword) + if err != nil { + log.Error("change password: breach checker error, failing open", map[string]string{"error": err.Error(), "user_id": userID}) + } else if breached { + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventPasswordBreachRejected, + UserID: userID, + }); auditErr != nil { + log.Error("change password: audit record failed", map[string]string{"error": auditErr.Error(), "user_id": userID}) + } + return ErrPasswordBreached + } + } + newHash, err := hasher.Hash(newPassword) if err != nil { return err diff --git a/auth/signup.go b/auth/signup.go index 280bff7..3f60014 100644 --- a/auth/signup.go +++ b/auth/signup.go @@ -10,12 +10,17 @@ import ( // SignUp creates a new user. callerIP is required and used only as a // rate-limit key and audit metadata — the engine never infers it. +// +// breachChecker is optional (nil-safe). A breachChecker error (the +// check service itself failing) fails open — it's logged, not treated +// as a rejection; only a confirmed breach blocks the password. func SignUp( ctx context.Context, users store.UserStore, hasher security.Hasher, ids security.IDGenerator, limiter security.RateLimiter, + breachChecker security.BreachedPasswordChecker, audit store.AuditStore, log logger.Logger, email string, @@ -38,6 +43,21 @@ func SignUp( return store.User{}, ErrUserExists } + if breachChecker != nil { + breached, err := breachChecker.IsBreached(ctx, password) + if err != nil { + log.Error("signup: breach checker error, failing open", map[string]string{"error": err.Error()}) + } else if breached { + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventPasswordBreachRejected, + IP: callerIP, + }); auditErr != nil { + log.Error("signup: audit record failed", map[string]string{"error": auditErr.Error()}) + } + return store.User{}, ErrPasswordBreached + } + } + hash, err := hasher.Hash(password) if err != nil { return store.User{}, err diff --git a/auth/signup_test.go b/auth/signup_test.go index 310617c..1e1053a 100644 --- a/auth/signup_test.go +++ b/auth/signup_test.go @@ -24,7 +24,7 @@ func TestSignUp_Success(t *testing.T) { users, audit, log, hasher, ids, limiter := newTestDeps() ctx := context.Background() - user, err := SignUp(ctx, users, hasher, ids, limiter, audit, log, "proguy@example.com", "pw", "1.2.3.4") + user, err := SignUp(ctx, users, hasher, ids, limiter, nil, audit, log, "proguy@example.com", "pw", "1.2.3.4") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -40,12 +40,12 @@ func TestSignUp_DuplicateEmailRejected(t *testing.T) { users, audit, log, hasher, ids, limiter := newTestDeps() ctx := context.Background() - _, err := SignUp(ctx, users, hasher, ids, limiter, audit, log, "proguy@example.com", "pw", "1.2.3.4") + _, err := SignUp(ctx, users, hasher, ids, limiter, nil, audit, log, "proguy@example.com", "pw", "1.2.3.4") if err != nil { t.Fatalf("unexpected error on first signup: %v", err) } - _, err = SignUp(ctx, users, hasher, ids, limiter, audit, log, "proguy@example.com", "different-pw", "1.2.3.4") + _, err = SignUp(ctx, users, hasher, ids, limiter, nil, audit, log, "proguy@example.com", "different-pw", "1.2.3.4") if err != ErrUserExists { t.Errorf("expected ErrUserExists, got %v", err) } @@ -56,12 +56,12 @@ func TestSignUp_RateLimited(t *testing.T) { limiter := security.NewInMemoryRateLimiter(1, time.Minute) ctx := context.Background() - _, err := SignUp(ctx, users, hasher, ids, limiter, audit, log, "a@example.com", "pw", "1.2.3.4") + _, err := SignUp(ctx, users, hasher, ids, limiter, nil, audit, log, "a@example.com", "pw", "1.2.3.4") if err != nil { t.Fatalf("expected first signup to succeed: %v", err) } - _, err = SignUp(ctx, users, hasher, ids, limiter, audit, log, "b@example.com", "pw", "1.2.3.4") + _, err = SignUp(ctx, users, hasher, ids, limiter, nil, audit, log, "b@example.com", "pw", "1.2.3.4") if err != ErrRateLimited { t.Errorf("expected ErrRateLimited for second signup from same IP, got %v", err) } diff --git a/cmd/smoketest/breached-password-check/main.go b/cmd/smoketest/breached-password-check/main.go new file mode 100644 index 0000000..fa442d1 --- /dev/null +++ b/cmd/smoketest/breached-password-check/main.go @@ -0,0 +1,119 @@ +// Command breached-password-check is a standalone, no-database smoke +// test for the breach-checking flow: a confirmed breach rejects the +// password, and a checker error fails open. Uses two tiny local fake +// checkers, not a real HIBP client — see +// docs/testing/breached-password-check.md for why, and how to verify +// against the real API instead. Run with: +// +// go run ./cmd/smoketest/breached-password-check +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/store/memory" +) + +var failures int + +// fakeChecker is a controllable stand-in for a real breach-checking +// service — reports a fixed result or a fixed error, and records how +// many times it was called. +type fakeChecker struct { + breached bool + err error + calls int +} + +func (f *fakeChecker) IsBreached(ctx context.Context, password string) (bool, error) { + f.calls++ + return f.breached, f.err +} + +func main() { + ctx := context.Background() + + // 1. A confirmed breach rejects the password. + breachedChecker := &fakeChecker{breached: true} + engine1, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + BreachedPasswordChecker: breachedChecker, + }) + check("engine 1 constructed", err) + + _, err = cryden.SignUp(ctx, engine1, "raymondproguy@dev.com", "password123", "1.2.3.4") + checkExpectError("signup with a confirmed-breached password is rejected", err) + if breachedChecker.calls != 1 { + fail(fmt.Sprintf("expected the checker to be called exactly once, got %d", breachedChecker.calls)) + } else { + pass("breach checker called exactly once") + } + + // 2. A checker error fails open — signup still succeeds. + erroringChecker := &fakeChecker{err: errors.New("simulated HIBP outage")} + engine2, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret-2", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + BreachedPasswordChecker: erroringChecker, + }) + check("engine 2 constructed", err) + + _, err = cryden.SignUp(ctx, engine2, "raymondproguy@dev.com", "password123", "1.2.3.4") + check("signup succeeds when the breach checker itself errors (fail open)", err) + + // 3. A clean password with no breach passes. + cleanChecker := &fakeChecker{breached: false} + engine3, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret-3", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + BreachedPasswordChecker: cleanChecker, + }) + check("engine 3 constructed", err) + + _, err = cryden.SignUp(ctx, engine3, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4") + check("signup with a clean, non-breached password succeeds", err) + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + } else { + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) + } +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +func checkExpectError(step string, err error) { + if err == nil { + fail(fmt.Sprintf("%s: expected an error, got nil", step)) + return + } + pass(fmt.Sprintf("%s (%v)", step, err)) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +} diff --git a/config.go b/config.go index 6b693d1..9e4e373 100644 --- a/config.go +++ b/config.go @@ -5,6 +5,7 @@ import ( "github.com/crydensync/cryden/v2/logger" "github.com/crydensync/cryden/v2/notify" + "github.com/crydensync/cryden/v2/security" "github.com/crydensync/cryden/v2/store" ) @@ -82,6 +83,11 @@ type Config struct { // ErrRecoveryCodesNotConfigured and Login never advertises // "recovery_code" as an available second-factor method. RecoveryCodes store.RecoveryCodeStore + // BreachedPasswordChecker is optional — only checked if set, on + // SignUp/ChangePassword. Ships no implementation (see the type's + // own doc comment); a checker error fails open rather than + // blocking the account action. + BreachedPasswordChecker security.BreachedPasswordChecker // Optional — sensible defaults applied in New() if zero-valued. // These are tuning knobs, not security-critical secrets, so diff --git a/cryden.go b/cryden.go index 8f74d91..a1e21b2 100644 --- a/cryden.go +++ b/cryden.go @@ -22,7 +22,7 @@ type Tokens = auth.Tokens // SignUp creates a new user. callerIP is required — used only for // rate limiting and audit metadata, never inferred by the engine. func SignUp(ctx context.Context, e *Engine, email, password, callerIP string) (store.User, error) { - return auth.SignUp(ctx, e.users, e.hasher, e.ids, e.rateLimiter, e.audit, e.log, email, password, callerIP) + return auth.SignUp(ctx, e.users, e.hasher, e.ids, e.rateLimiter, e.breachChecker, e.audit, e.log, email, password, callerIP) } // Login authenticates a user and issues a new session. callerIP and @@ -39,7 +39,7 @@ func Login(ctx context.Context, e *Engine, email, password, callerIP, userAgent // ChangePassword requires the caller's current password as // re-confirmation, and revokes all sessions on success. func ChangePassword(ctx context.Context, e *Engine, userID, currentPassword, newPassword string) error { - return auth.ChangePassword(ctx, e.users, e.sessions, e.hasher, e.audit, e.log, userID, currentPassword, newPassword) + return auth.ChangePassword(ctx, e.users, e.sessions, e.hasher, e.breachChecker, e.audit, e.log, userID, currentPassword, newPassword) } // DeleteAccount requires the caller's current password as diff --git a/docs/testing/breached-password-check.md b/docs/testing/breached-password-check.md new file mode 100644 index 0000000..e08fe1c --- /dev/null +++ b/docs/testing/breached-password-check.md @@ -0,0 +1,63 @@ +# Manual testing: Breached-password check + +## Fastest check — in-memory smoke test + +No database and no real HIBP call needed: + +```bash +go run ./cmd/smoketest/breached-password-check +``` + +Uses two tiny local fake checkers (not a real HIBP client — see below +for why) to demonstrate the actual contract: a confirmed breach +rejects the password with `auth.ErrPasswordBreached`, a checker error +fails open (signup/change still succeeds), and the checker is never +called at all if the password already fails the policy check first. + +## Why the smoke test doesn't call the real HIBP API + +`security.BreachedPasswordChecker` ships zero implementations +on purpose (see the README) — this is the one place in the engine +where an outbound network call is the entire point, so it's left +entirely to the consuming app. A "smoke test" that itself shipped a +real HIBP client would quietly become a shipped implementation, +undermining that design choice. If you want to verify against the +real API: + +1. Implement the interface against `https://api.pwnedpasswords.com/range/{prefix}` + (see the README's example implementation). +2. Wire it into `Config.BreachedPasswordChecker`. +3. Try signing up with a genuinely breached password (e.g. `password123`, + `qwerty123456` — anything you'd find in a "worst passwords" list) + and confirm you get `auth.ErrPasswordBreached`. +4. Try a random, never-used string — confirm it passes. +5. Point the checker at an unreachable URL temporarily and confirm + signup still succeeds (fail-open). + +## Unit tests + +```bash +go test ./auth/... +``` + +Specifically relevant: `auth/passwordpolicy_test.go` — covers a +confirmed breach rejecting SignUp/ChangePassword, a checker error +failing open, the checker never being called when the (cheaper, local) +policy check already failed, and the rejection being recorded as a +`password_breach_rejected` audit event. + +## What "working" looks like, in plain terms + +- A password the checker confirms as breached is rejected outright, + on both signup and password change. +- If the checker itself fails (network error, timeout, HIBP down), + the action still succeeds — a third-party API's uptime should never + be able to block your users from signing up or changing their + password. +- The breach check is skipped entirely if the password already + violates the configured policy — no reason to make an external call + for input you were already going to reject. +- On `ChangePassword` specifically: the *current* password is verified + before the *new* password's breach status is checked — someone who + doesn't already know the current password never learns anything + about whether their guessed new password would pass. diff --git a/engine.go b/engine.go index 37dcbef..7abf0b3 100644 --- a/engine.go +++ b/engine.go @@ -24,6 +24,7 @@ type Engine struct { webauthn store.WebAuthnCredentialStore magicLinkSender notify.MagicLinkSender recoveryCodes store.RecoveryCodeStore + breachChecker security.BreachedPasswordChecker hasher security.Hasher ids security.IDGenerator @@ -107,6 +108,7 @@ func New(cfg Config) (*Engine, error) { webauthn: cfg.WebAuthn, magicLinkSender: cfg.MagicLinkSender, recoveryCodes: cfg.RecoveryCodes, + breachChecker: cfg.BreachedPasswordChecker, hasher: hasher, ids: security.NewUUIDv7Generator(), rateLimiter: security.NewInMemoryRateLimiter(cfg.RateLimitAttempts, cfg.RateLimitWindow), diff --git a/go.mod b/go.mod index ccd1b6f..55a84b2 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,18 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.12.3 github.com/pquerna/otp v1.5.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.3.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/sys v0.47.0 // indirect ) // github.com/pquerna/otp pulls in boombuler/barcode transitively (used diff --git a/go.sum b/go.sum index dd355d6..0224791 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,44 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ= +github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA= +github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg= +github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE= +github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/security/breachcheck.go b/security/breachcheck.go new file mode 100644 index 0000000..ba2d5f6 --- /dev/null +++ b/security/breachcheck.go @@ -0,0 +1,22 @@ +package security + +import "context" + +// BreachedPasswordChecker defines a check for whether a password has +// appeared in a known data breach. Like EmailSender/MagicLinkSender, +// this ships zero production implementations — checking this +// necessarily means an outbound network call (e.g. to HIBP's +// k-anonymity API), and the engine never talks to the internet on its +// own initiative anywhere else, so it doesn't start here either. The +// consuming app implements this against HIBP, a self-hosted breached- +// password list, or anything else that fits. +type BreachedPasswordChecker interface { + // IsBreached reports whether password has appeared in a known + // breach. A non-nil error means the check itself failed (e.g. the + // host's HIBP integration is unreachable) — callers should treat + // that as "unknown," not "breached": SignUp/ChangePassword fail + // open on a checker error, since blocking account creation on a + // third-party API's uptime is a worse tradeoff than the security + // gained. + IsBreached(ctx context.Context, password string) (bool, error) +} diff --git a/store/interfaces.go b/store/interfaces.go index 92a8294..0742ae2 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -130,6 +130,7 @@ const ( EventRecoveryCodesGenerated AuditEventType = "recovery_codes_generated" EventRecoveryCodeUsed AuditEventType = "recovery_code_used" EventRecoveryCodeFailed AuditEventType = "recovery_code_failed" + EventPasswordBreachRejected AuditEventType = "password_breach_rejected" ) // AuditEvent is a single security-relevant, queryable record.