Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions auth/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
107 changes: 107 additions & 0 deletions auth/breach_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 4 additions & 0 deletions auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
24 changes: 21 additions & 3 deletions auth/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions auth/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions auth/signup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading
Loading