CrydenSync

Security

How Cryden handles secrets, timing, rate limiting, detection, storage, and authorization, in detail.

This page is a detailed technical account of Cryden's security-relevant behavior, one topic at a time. See Design Decisions for the reasoning behind specific choices, and Features for how to turn each of these on.

Secrets at rest

The engine treats storage of secrets as a one-way street. Passwords default to bcrypt at cost 10, and can be switched to Argon2id through Config.Hasher with RFC 9106 defaults of 64 MiB, t equals 3, and p equals 4. The engine always wraps the chosen hasher in a MultiHasher that identifies the algorithm from each stored hash's own prefix, so existing hashes keep verifying across a switch with no migration. A successful login that finds an out-of-date hash rewrites it with the current hasher and records a password_hash_upgraded audit event, which is how a user base migrates over time.

Refresh tokens, recovery codes, and API keys are high-entropy random values stored only as SHA-256 hashes; the raw value exists once, in the return value, and can never be retrieved again. TOTP secrets are the deliberate exception: they are encrypted at rest with AES-GCM derived from Config.EncryptionKey rather than hashed, because validating a code requires recovering the secret, and that key is a separate secret from JWTSecret. WebAuthn ceremony state is encrypted with the same key.

Rate limiting and lockout

Rate limiting defaults to an in-process fixed-window counter of 10 attempts per minute per key, correct for one process. Config.RateLimiter accepts a shared implementation, and the shipped RedisRateLimiter runs an atomic INCR/PEXPIRE script so every engine instance counts against one window. It supports redis.Client, ClusterClient, and Ring, namespaces keys under a configurable prefix, and fails closed when Redis is unreachable, a documented availability trade-off.

Account lockout is persistent and database-backed, defaulting to 5 failed attempts per 15 minutes, so it survives restarts and holds across multiple instances. Login equalizes timing between a nonexistent email and a wrong password by running the same hashing cost either way and returning the same ErrInvalidCredentials, which blocks email enumeration by timing. Refresh tokens rotate on every use inside session families, and presenting an already-rotated token revokes the entire family and records a token_reuse_detected audit event.

Detection

Login anomaly detection and credential-stuffing detection are both report-only by design. The first evaluates completed primary authentications against a baseline built only from recent successful logins, and flags new_ip, new_device, user_failure_velocity, ip_failure_velocity, token_reuse, and concurrent_sessions. The second measures one IP's breadth of failures across distinct accounts and unknown emails, and emits account_spray or unknown_account_spray with a cooldown. Neither ever blocks, delays, or forces step-up authentication, and storage failures degrade to no evidence rather than false alarms.

No outbound calls on the engine's own initiative

The engine makes no outbound network call on its own initiative. BreachedPasswordChecker, IPGeolocator, EmailSender, MagicLinkSender, WebhookSender, Logger, and LLMProvider are interfaces the host implements, which keeps the zero-telemetry promise structural. The breached-password check is fail-open by design: an outage must not block signups, and only a confirmed breach rejects the password.

Authentication

SignUp validates the password policy, optionally checks breach status, hashes, and records audit events. Login rate limits per caller IP and email, checks lockout, verifies the hash, increments and resets failed-attempt counters, and issues a fresh session family on success. ChangePassword requires the current password and revokes all other sessions; DeleteAccount requires the current password as re-confirmation; Logout, LogoutAll, and session revocation all verify ownership before acting.

OAuth login is provider-agnostic and runs after the host has completed the provider redirect. It doubles as signup, and refuses to auto-link when the provider email matches an existing password account, returning ErrOAuthEmailConflict instead, because auto-linking on email match alone is treated as an account-takeover vector. The resolution is a password login followed by LinkOAuthIdentity with a user ID from an already-verified session.

TOTP, WebAuthn passkeys, and recovery codes share one unified second-factor pause state. Login returns ErrSecondFactorRequired with a short-lived, single-use pending token and the list of enrolled methods; completion goes through CompleteLoginWithTOTP, the WebAuthn begin and finish ceremony pair, or CompleteLoginWithRecoveryCode. TOTP enrollment is a two-step confirm flow, so a secret never gates login before the user proves possession, and disabling TOTP or deleting a passkey requires the current password. Recovery codes are single-use, stored only as hashes, shown exactly once, and atomically replaced by a new batch; they are only offered alongside a real second factor, so they can never become a standalone backdoor.

Magic links log in existing accounts only, are valid for fifteen minutes, are single-use, and return nil for nonexistent emails to avoid enumeration; completion routes through the same second-factor gate as a password login. Email change requires verification of the new address through a purpose-distinguished token before it takes effect. Access tokens are HMAC JWTs with a fifteen-minute default TTL, and refresh tokens are opaque and rotating.

API keys are a parallel machine-to-machine path, deliberately outside sessions and second factors because there is no human at the end of a request. GenerateAPIKey returns the raw key exactly once with a configurable non-secret prefix, keys carry host-defined scopes and optional expiry, revocation is permanent, and AuthenticateAPIKey fails uniformly for unknown, revoked, expired, or malformed keys, so callers cannot probe which stolen keys are still live.

Authorization

Authorization data never lives in the engine as a decision, but the engine gives hosts the pieces to enforce it. Access tokens can carry host-supplied claims, such as role or tenant ID, through a ClaimsProvider; the seven registered RFC 7519 claim names are reserved and refused, provider errors fail the login rather than issuing an under-authoritative token, and VerifyTokenWithClaims reads the claims back.

API key scopes are opaque host-defined strings, stored and returned verbatim. The engine never interprets one; HasScope is an exact-match convenience, and a host wanting hierarchy or wildcards owns that comparison. OAuth identity linking is gated on an already-verified session rather than on an email alone, and session revocation is scoped to the owning user in a single statement, so a caller cannot learn whether another user's session exists.

The AI-assisted admin query layer is authorization by allowlist. An LLM's output must parse into a strictly typed QueryIntent whose entity, fields, operators, aggregates, and limits are validated before any query executes; only the users, sessions, and audit_events entities are reachable, password hashes and token hashes are excluded from every field list, and result sizes are capped. The production query stores must be driven by a read-only database role, because the credential boundary, not the allowlist alone, is the real safety guarantee.

Storage

The store package defines the domain types and interfaces the engine depends on: UserStore, SessionStore, AuditStore, VerificationStore, OAuthStore, TOTPStore, WebAuthnCredentialStore, RecoveryCodeStore, APIKeyStore, and AnomalyStore. Everything else is injected, so the engine never hardcodes a backend.

store/memory implements every interface in-process, which is what makes the quickstart and the smoke tests run with zero setup. store/postgres is the primary production backend, requires Postgres 13 or later for gen_random_uuid, relies on multi-statement transactions for atomic token rotation, and ships sequential migrations 0001 through 0007 covering the initial schema, OAuth identities, TOTP secrets, WebAuthn credentials, recovery codes, login attempts, and API keys. store/sqlite is a second production backend that imports no driver at all; the host registers mattn, modernc, or ncruces and passes an opened sql.DB. Its DSN pragmas for foreign_keys, busy_timeout, and WAL are load-bearing, and Migrate and CheckPragmas make setup and verification explicit. Tests exercise SQLite through the pure-Go modernc driver, so go test works with CGO disabled.

The store interfaces also carry the read surfaces admin tooling needs: paginated user listing, system-wide user and session counts, system-wide audit search by type, and exact per-type counts over a window. Webhooks decorate the audit store, so digest counts work through the decorated store exactly as through a plain one.

Features that build on all of the above

Weekly digest. cryden.WeeklyDigest summarizes the last seven days of audit history as plain text, and cryden.DigestSince does the same for a custom window. Counts are exact and come from the database, attention-worthy events such as lockouts, token reuse, anomalies, and stuffing bursts are spelled out newest first up to ten per type, unknown event types are reported under an "other events" section, and the whole feature is read-only by construction through the admin package's narrow AuditReader interface.

Support-ticket assistant. cryden.DiagnoseLoginIssue answers the support question of why a user cannot log in, as plain text assembled only from what is already recorded: whether the account is locked and until when, its current failed-attempt count, how many sessions it holds right now, and the newest failure events by type, including failed TOTP or passkey challenges, rejected recovery codes, and anomaly or stuffing flags. The report is built through admin.DiagnoseLogin over narrow reader interfaces with no unlock, reset, or revoke method, so diagnosing cannot change the account it reports on, and a nonexistent account is the answer rather than an error.

Config tuning advisor. cryden.ConfigTuningReport and cryden.TuningReportSince read audit history over a window, 30 days by default, against the engine's own settings: lockout threshold and duration, whether the in-process default rate limiter is still in use, anomaly and credential-stuffing thresholds, and whether breached-password checking is active. The plain-text result lists areas worth a second look with concrete suggestions; admin.BuildTuningReport has no write path, so a human decides what, if anything, changes in Config.

Webhooks. Config.Webhooks accepts a notify.WebhookSender, and the engine hands over every subscribed event as it is recorded, synchronously on the request path. An error is logged and never fails the operation being reported, a panic propagates, events carry stable delivery IDs for idempotent receivers, and the default event set is the actionable low-volume subset that deliberately excludes login_success, login_failed, and token_rotated.

Named sessions. ListNamedSessions labels each active session with a parsed device and an optional geolocated location, composing labels such as "Chrome on Windows, San Francisco, CA." The device half is a built-in user-agent parser, the location half is host-supplied through IPGeolocator with fail-open degradation, and labels are derived on read, so existing sessions get them retroactively.

AI-assisted admin queries. The ai package ships the allowlisted validation, ExecuteQuery composition, the exported ExecuteIntent tail, and the read-only query stores described above, as a foundation for tooling such as a CLI; no LLM provider is shipped, the host brings one.

Ask-AI widget. widget.Ask exposes that same machinery to a host application's own end users, for questions scoped to their own user row, sessions, and audit events. There is no second query mechanism: Provider and Store are the ai package's interfaces, and before any parsed intent reaches validation, the identity filter is overwritten unconditionally with the user ID supplied by the host's own authentication. Prompt injection can shape what a model tries to produce but not what the code lets it read, and an entity the scoper cannot bind to one owner fails closed.

Logging toolkit. The logger package defines a frozen Logger interface, a JSON-per-line ConsoleJSONLogger default, ContextLogger and LogFunc for trace correlation, LevelFilter to drop debug records, a MaskingRedactor for PII before records leave the infrastructure, and MultiLogger to fan out to several sinks while keeping full detail local.

Read facades. ListSessions, ListPublicSessions, GetUser, ListAll, Count, CountActive, and SearchByType let host applications build admin tooling without bypassing the store layer.

Testing and operations

The repository contains 14 packages plus 21 standalone smoke-test commands that walk through every feature with no database required, unit tests across all packages, real WebAuthn ceremonies through virtualwebauthn, SQLite integration tests on real files, and Postgres integration tests that skip cleanly without DATABASE_URL. GitHub Actions builds, vets, and tests against Postgres 16 with all migrations applied.