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
425 changes: 425 additions & 0 deletions cmd/smoketest/named-sessions/main.go

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions cryden.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import (
// RefreshToken.
type Tokens = auth.Tokens

// NamedSession is an active session plus its human-readable label,
// as returned by ListNamedSessions.
type NamedSession = session.NamedSession

// 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) {
Expand Down Expand Up @@ -182,6 +186,20 @@ func ListPublicSessions(ctx context.Context, e *Engine, userID string) ([]store.
return out, nil
}

// ListNamedSessions is ListPublicSessions with a human-readable label
// on each session — "Chrome on Windows — San Francisco, CA" instead of
// a bare session ID, for a settings page that expects someone to
// recognize their own devices and revoke the one they don't.
//
// Labels are derived on read from the IP and User-Agent already stored
// on each session, so this works retroactively on sessions created
// before it existed. The device half always resolves (to "Unknown
// device" at worst); the location half needs Config.Geolocator, and is
// simply omitted when that is unset or its lookup fails.
func ListNamedSessions(ctx context.Context, e *Engine, userID string) ([]NamedSession, error) {
return session.ListNamed(ctx, e.sessions, e.geolocator, e.log, userID)
}

// RevokeSession revokes a specific session. Verifies ownership before
// revoking.
func RevokeSession(ctx context.Context, e *Engine, sessionID, userID string) error {
Expand Down
57 changes: 50 additions & 7 deletions docs/development/CURRENT-STATE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# cryden — current state

Last updated: 2026-09-04 (by the session that built
credential-stuffing detection). Update this file's date and content every time a session
named/fingerprinted sessions). Update this file's date and content every time a session
finishes an item — see `CLAUDE.md`'s end-of-session checklist.

## Tagged releases
Expand All @@ -24,7 +24,7 @@ If you find a real bug in it while working on something else, fix it
on its own small branch and note it in `PROGRESS.md` — don't treat
finding it as license to re-audit the rest.

## Tier 2 — Security & Monitoring: IN PROGRESS (2 of 4 done)
## Tier 2 — Security & Monitoring: IN PROGRESS (3 of 4 done)

### Item 8 — anomaly detection: DONE, branch `feat/anomaly-detection`

Expand Down Expand Up @@ -104,7 +104,44 @@ failures against ONE account is deliberately not flagged), 2 more in
test: `cmd/smoketest/credential-stuffing` (99 checks). Manual guide:
`docs/testing/credential-stuffing.md`.

### Items 10-11: NOT STARTED
### Item 10 — named/fingerprinted sessions: DONE, branch `feat/named-sessions`

Not merged — the human reviews and pushes. Same "don't re-verify" note
as everything above.

`NEXT.md` called this the vaguest item in the backlog and expected a
documented judgment call. The call: a session's label is **computed on
read** from the `IP` and `UserAgent` `store.Session` already carries.
No column, no table, **no migration** — so every session ever recorded
gets a label the first time it is listed, and improving the parser later
improves old sessions retroactively. `PROGRESS.md` has the full
reasoning, including the alternatives rejected.

Shipped as: `security/useragent.go` (pure parsing — `Device` with
`Browser`/`OS`/`Form`, the `FormDesktop`/`FormMobile`/`FormTablet`/
`FormBot` constants, `Device.String()`, `Device.IsZero()`,
`ParseUserAgent`), `security/geolocation.go` (`Location` with
`String()`/`IsZero()`, plus the `IPGeolocator` interface — **zero
shipped implementations**), `session/named.go` (`NamedSession` embedding
`store.PublicSession`, the exported `Label` composer, and `ListNamed`),
the `ListNamedSessions` facade with a `cryden.NamedSession` alias, and
`Config.Geolocator`. No store interface, migration or query was touched.

The two halves are deliberately asymmetric: parsing ships as real engine
code because it needs nothing the engine doesn't already hold, while
placing an IP ships as an interface only because it means an outbound
call or a licensed database — the `BreachedPasswordChecker` rule. Left
nil, labels are device-only and nothing else changes; a geolocator error
costs a label, never the listing; it is asked once per distinct IP per
call.

Tests: `security/useragent_test.go` (3 funcs, 23 authentic-User-Agent
subtests), `security/geolocation_test.go` (2), `session/named_test.go`
(8), plus 2 in `config_test.go` and 3 in `new_facade_test.go`. Smoke
test: `cmd/smoketest/named-sessions` (42 checks). Manual guide:
`docs/testing/named-sessions.md`.

### Item 11: NOT STARTED

Detailed specs in `NEXT.md`. The design decision recorded for item 8
below is kept for reference — it is what the shipped code implements.
Expand Down Expand Up @@ -136,10 +173,9 @@ below is kept for reference — it is what the shipped code implements.
`login_attempts` table with three partial indexes — plus
`CountTargetsForIP`, added by item 9 above against the same table.

Items 10 and 11 (named/fingerprinted sessions, Redis-backed rate
limiter) have no prior design decisions recorded — see `NEXT.md` for the
level of detail available, make reasonable calls on anything
unspecified, note them in `PROGRESS.md`.
Item 11 (Redis-backed rate limiter) has no prior design decisions
recorded — see `NEXT.md` for the level of detail available, make
reasonable calls on anything unspecified, note them in `PROGRESS.md`.

## Tier 3 — Infrastructure & Extensibility: NOT STARTED

Expand Down Expand Up @@ -169,6 +205,13 @@ project brief.
it. So this branch contains item 8's six commits too: merging it
lands both items, and merging item 8 first makes this one a clean
fast-forward. Unmerged and unpushed.
- `feat/named-sessions` — item 10, complete, 7 commits, branched from
`feat/credential-stuffing` at `36690bf`, the tip of the chain, so this
branch carries items 8, 9 and 10. Item 10 has no functional dependency
on the earlier two — it reads no store or config they added — but its
`config.go`/`engine.go` additions sit directly above theirs, so lifting
it onto `main` alone means resolving that adjacency by hand. Unmerged
and unpushed.

Nothing else in flight. Each new session picks the top item off
`NEXT.md`, creates its own branch, and this section should be updated to
Expand Down
48 changes: 12 additions & 36 deletions docs/development/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,7 @@ patterns and note the assumption in `PROGRESS.md` — don't block on it.

## Tier 2 — Security & Monitoring

### 1. Named/fingerprinted sessions (item 10) — genuinely underspecified, use judgment

Current `store.Session` already has `IP` and `UserAgent`. "Named/
fingerprinted" most likely means: a human-readable label for "your
active sessions" UI (e.g. "Chrome on Windows — San Francisco, CA")
instead of a raw session ID.

- User-Agent → device/browser string: pure parsing, no network call,
no external API — fine to ship as a real engine-side helper (or a
small interface if you think host apps would want to swap parsing
libraries; use your judgment, this is a minor decision either way).
- IP → location string: this DOES require geolocation data from
somewhere. Follow the established rule — if it needs an outbound
network call, it's a new interface (e.g. `security.IPGeolocator`)
with **zero shipped implementations**, host supplies one, exactly
like `BreachedPasswordChecker`. Do not bake in a call to any
specific geo-IP service directly.
- If geolocation feels like it belongs entirely at the `api`/host-app
layer instead of the engine (since the engine already exposes raw
IP on every session), that's a legitimate alternative — note your
reasoning in `PROGRESS.md` either way, this is the item where the
original backlog line is vaguest and a documented judgment call is
expected.

### 2. Redis-backed rate limiter (item 11)
### 1. Redis-backed rate limiter (item 11)

`security.RateLimiter` already exists with one implementation
(in-memory, documented as not safe across multiple instances). This is
Expand All @@ -57,7 +33,7 @@ from a connection string — match that pattern here too).

## Tier 3 — Infrastructure & Extensibility

### 3. Argon2id as an additional trusted hasher (item 12)
### 2. Argon2id as an additional trusted hasher (item 12)

Second implementation of `security.Hasher`, not a replacement for
bcrypt. Real design question: how does the engine know which
Expand All @@ -68,7 +44,7 @@ dispatching `Compare`, while `Hash` always uses whichever algorithm is
currently configured. Build it this way unless you find a strong
reason not to; note the reasoning either way.

### 4. Additional storage backend beyond Postgres (item 13)
### 3. Additional storage backend beyond Postgres (item 13)

Every `store.X` interface already exists — implement all of them
against a second backend (SQLite is the most likely candidate per
Expand All @@ -79,7 +55,7 @@ specific assumptions baked into existing interface docs/behavior
`store/postgres/` implementations lean on these and a different
backend will need different real solutions, not just syntax swaps.

### 5. Cloud logger integrations (item 14)
### 4. Cloud logger integrations (item 14)

`logger.Logger` already exists with one implementation (console JSON).
Decide interface-only-vs-shipped-implementation the same way as
Expand All @@ -92,7 +68,7 @@ console-JSON-to-stdout is already the universal integration point
there's a specific strong reason a direct integration adds real value
over "the host app already captures stdout."

### 6. Extensible JWT claims (item 15)
### 5. Extensible JWT claims (item 15)

Let host apps attach their own data to access tokens. Read
`token/jwt.go`'s current claims struct and `JWTIssuer.Issue` before
Expand All @@ -103,7 +79,7 @@ signing-method check). Likely shape: `Issue` gains an optional
`ClaimsProvider` hook — pick whichever fits the existing `Issue`
call sites with the least disruption.

### 7. API keys / machine-to-machine auth (item 16)
### 6. API keys / machine-to-machine auth (item 16)

New concept, not a variant of an existing one — no human to prompt, so
this sits outside the second-factor system entirely (confirm this
Expand All @@ -115,7 +91,7 @@ values, not human passwords), and its own facade functions
(`GenerateAPIKey`, `RevokeAPIKey`, and something that validates a
presented key and returns which user/scope it belongs to).

### 8. Webhooks (item 17)
### 7. Webhooks (item 17)

Notify the host app on key events. Same question as everything else
that reaches outward: interface-only, zero shipped implementations
Expand All @@ -127,7 +103,7 @@ subset, not all of them) and wire it in wherever `audit.Record` is
already called for those events — don't build a second parallel event
bus.

### 9. Custom email templates (item 18)
### 8. Custom email templates (item 18)

Check `notify.EmailSender`/`notify.MagicLinkSender` as they exist
today first — there's a real chance this needs **no engine change at
Expand All @@ -145,19 +121,19 @@ than building something speculative to have built something.
automatic action — no auto-lock, no auto-config-change, nothing. Every
one of these produces information for a human to act on.

### 10. Weekly digest (item 19)
### 9. Weekly digest (item 19)
Reads `AuditStore`, summarizes in plain English, returns text. Nothing
else.

### 11. Support-ticket assistant (item 20)
### 10. Support-ticket assistant (item 20)
Read-only diagnosis ("why can't user X log in") — queries
`AuditStore`/`UserStore`/session state, produces an explanation, never
touches anything.

### 12. Config tuning advisor (item 21)
### 11. Config tuning advisor (item 21)
Produces a report of suggested config changes. Never applies them.

### 13. Ask-AI widget (item 22)
### 12. Ask-AI widget (item 22)
The most complex of the four. Needs its own full design pass before
any code — at minimum: an LLM provider interface (zero shipped
implementations, host brings their own key/provider, same pattern as
Expand Down
75 changes: 75 additions & 0 deletions docs/development/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,78 @@ pre-existing, still unfixed, still worth its own small branch.

Next in queue: item 10, named/fingerprinted sessions — the item where
`NEXT.md` explicitly expects a documented judgment call.

## 2026-09-04 — Named/fingerprinted sessions (item 10)

Branch: `feat/named-sessions` (7 commits, unmerged, unpushed, branched
from `feat/credential-stuffing` at `36690bf` — the tip of the chain, so
this branch carries items 8, 9 and 10).

Built: a human-readable label for each active session —
"Chrome on Windows — San Francisco, CA" instead of a UUID — for a "your
devices" settings page. `security/useragent.go` parses the device half
(`Device`, `ParseUserAgent`, the `Form*` constants), `security/
geolocation.go` defines the location half as an interface with **zero
implementations** (`IPGeolocator`, `Location`), `session/named.go`
composes them (`NamedSession`, the exported `Label`, `ListNamed`), and
the facade exposes `ListNamedSessions` plus `Config.Geolocator`. No store
change, no migration, no new query.

`NEXT.md` flagged this as the vaguest item in the backlog and asked for
the reasoning in writing, so:

- **Labels are computed on read, not stored.** The `IP` and `UserAgent`
needed are already on `store.Session`. Storing a derived string would
add a column, a migration and a backfill to own a value that can be
recomputed for free — and would freeze old sessions at whatever the
parser knew on the day they were created. As built, every session ever
recorded gets a label the first time it's listed, and improving the
parser improves history retroactively. The smoke test checks exactly
this by listing the same stored session from two engines.
- **The user-agent parser ships for real, with no swap interface.**
`NEXT.md` left this open. Parsing is pure string matching over data the
engine already holds, so the "engine never reaches outward" rule
doesn't apply, and an interface with no implementation would ship a
feature that does nothing by default. A host wanting a different
library runs it over `store.Session.UserAgent`, which stays exposed
verbatim — so the escape hatch already exists without a second
interface to configure.
- **Geolocation is interface-only, `Config.Geolocator`, zero shipped
implementations** — the `BreachedPasswordChecker` rule, unchanged.
This is the half the engine structurally cannot compute. I kept it in
the engine rather than pushing it entirely to the host app (the
alternative `NEXT.md` offered) because the composition is the feature:
a label needs both halves in one string, and leaving location at the
host layer means every host re-implements label formatting. The
interface is one method and costs nothing to leave nil.
- **`Location` granularity is the host's choice.** `String()` joins the
non-empty fields with ", " and does nothing else — no abbreviating,
no expanding, no inferring a country from a region. A host filling in
City+Region gets "San Francisco, CA"; adding Country gets
"San Francisco, CA, US".
- **Fails open, and asks once per distinct IP per call.** A geolocator
error is logged and treated as "location unknown"; the listing itself
never fails, because that list is how someone revokes an attacker's
session. The per-call cache (not per-process) avoids N lookups for a
laptop and phone on one address without owning an invalidation story.
- **No user-editable nicknames.** "Named" here means engine-derived. A
host that wants "Ray's work laptop" stores that itself keyed by session
ID; adding a display-name column would be a storage feature wearing
this item's name.
- **No version numbers in labels**, and bots/CLI clients report no OS —
"Bingbot on Windows" would be a device claim a bot's UA can't support.

Verification: `gofmt -l .` clean, `go build ./...`, `go vet ./...` and
`go test ./...` all run clean here, and the smoke test passes all 42
checks. Nothing in this item touches storage, so there is no Postgres
path left unexercised — `ListByUser` is the only store call involved and
it predates this work. The parser is tested against authentic
User-Agent strings rather than invented ones, since the only real risk
in it is browsers impersonating each other inside the header (the CUBOT
case is why the generic bot heuristic runs after browser matching).

The `TestLogin_NonexistentUserTimingMatchesWrongPassword` flake noted in
items 8 and 9 did not recur this session. Still unfixed, still worth its
own small branch.

Next in queue: item 11, the Redis-backed rate limiter.
Loading
Loading