From ec50e9d5d8867bfaa3d5dc366d0ea3a57cdc9a17 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 21:57:42 +0100 Subject: [PATCH 1/7] feat: add user-agent parsing and the IP geolocation interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two halves of a session label. Parsing ships for real — it needs nothing the session doesn't already store. Locating an address needs an outbound call or a licensed database, so IPGeolocator ships with zero implementations, exactly like BreachedPasswordChecker. --- security/geolocation.go | 72 +++++++++++++ security/useragent.go | 231 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 security/geolocation.go create mode 100644 security/useragent.go diff --git a/security/geolocation.go b/security/geolocation.go new file mode 100644 index 0000000..466d99d --- /dev/null +++ b/security/geolocation.go @@ -0,0 +1,72 @@ +package security + +import ( + "context" + "strings" +) + +// Location is a coarse, human-readable place derived from an IP +// address — the "San Francisco, CA" half of a session label like +// "Chrome on Windows — San Francisco, CA". +// +// Every field is optional, and String() simply omits the empty ones, +// which is how granularity is chosen: a host serving one country +// returns City+Region and gets "San Francisco, CA", a host serving +// many returns Country too and gets "San Francisco, CA, US". The +// engine formats exactly what its IPGeolocator hands over and never +// invents, expands or abbreviates a field. +type Location struct { + City string + Region string // state, province or region — whatever the host's data has + Country string +} + +// IsZero reports whether no part of the location is known. A +// geolocator that cannot place an address should return the zero +// Location rather than an error, which is not an anomaly: private, +// reserved and carrier-NAT addresses are routinely unplaceable. +func (l Location) IsZero() bool { + return strings.TrimSpace(l.City) == "" && + strings.TrimSpace(l.Region) == "" && + strings.TrimSpace(l.Country) == "" +} + +// String joins the non-empty fields with ", " — "San Francisco, CA, +// US", "Berlin, DE", "US", or "" when nothing is known. +func (l Location) String() string { + parts := make([]string, 0, 3) + for _, part := range []string{l.City, l.Region, l.Country} { + if trimmed := strings.TrimSpace(part); trimmed != "" { + parts = append(parts, trimmed) + } + } + return strings.Join(parts, ", ") +} + +// IPGeolocator resolves an IP address to a coarse Location for +// display in a session list. Like BreachedPasswordChecker and +// EmailSender/MagicLinkSender, this ships zero production +// implementations — placing an address means either an outbound +// network call or a licensed geo-IP database on disk, and the engine +// neither talks to the internet on its own initiative nor takes on +// third-party data files, so it doesn't start here either. The +// consuming app implements this against MaxMind, ipinfo, its CDN's +// own edge headers, or anything else that fits. +// +// This is the one half of a session label the engine structurally +// cannot compute for itself: the device half comes from +// ParseUserAgent, which needs nothing but the string already stored on +// the session. +type IPGeolocator interface { + // Locate resolves ip to a Location. Return the zero Location with + // a nil error for an address that is simply unplaceable (private + // range, carrier NAT, unknown); return an error only when the + // lookup itself failed, e.g. the provider was unreachable. + // + // Either way the session label degrades to its device half and the + // call that asked for it still succeeds — session listing fails + // open on a geolocator error, for the same reason SignUp fails + // open on a breach-check error: a third-party provider's uptime + // should not decide whether someone can see their own devices. + Locate(ctx context.Context, ip string) (Location, error) +} diff --git a/security/useragent.go b/security/useragent.go new file mode 100644 index 0000000..9c593cd --- /dev/null +++ b/security/useragent.go @@ -0,0 +1,231 @@ +package security + +import "strings" + +// Form factor values reported by Device.Form. Deliberately coarse — a +// User-Agent string cannot reliably distinguish more than this, and a +// "your devices" list does not need it to. +const ( + FormDesktop = "desktop" + FormMobile = "mobile" + FormTablet = "tablet" + FormBot = "bot" +) + +// Device is what ParseUserAgent recovered from a User-Agent header: +// enough for someone to recognize their own login in a session list, +// and nothing more. +// +// Every field is best-effort and may be empty. A User-Agent is +// unauthenticated, client-supplied text that browsers actively lie in +// — Chrome's own string claims to be Safari, Edge's claims to be +// Chrome, and anything at all can send anything at all. Treat this as +// a recognition aid for a human reading a list, never as an identity, +// a device fingerprint, or an input to a security decision. +type Device struct { + // Browser is a display name like "Chrome", "Safari", "Firefox", + // "Googlebot" or "curl". Empty when nothing recognizable matched. + Browser string + // OS is a display name like "Windows", "macOS", "iOS", "Android", + // "Linux" or "ChromeOS". Empty when nothing recognizable matched, + // and always empty for bots and command-line clients — "Bingbot on + // Windows" would read as a device claim the string cannot support. + OS string + // Form is one of the Form* constants above, or "" when it can't be + // told. + Form string +} + +// IsZero reports whether nothing at all was recognized. +func (d Device) IsZero() bool { return d == Device{} } + +// String renders the device as a short human-readable phrase: +// "Chrome on Windows", or just "Chrome"/"Windows" when only one half +// was recognized, or "Unknown device" when neither was. Never returns +// an empty string — a session list needs something to print in every +// row, including the row for a client that sent no User-Agent at all. +func (d Device) String() string { + switch { + case d.Browser != "" && d.OS != "": + return d.Browser + " on " + d.OS + case d.Browser != "": + return d.Browser + case d.OS != "": + return d.OS + default: + return "Unknown device" + } +} + +// ParseUserAgent extracts a Device from a raw User-Agent header. Pure +// string matching: no network call, no external service, no lookup +// table to keep updated — an unrecognized string degrades to an empty +// Device (which still prints as "Unknown device") rather than being +// an error, because a login already happened and the UI still has a +// row to fill. +// +// The engine ships this rather than an interface with no +// implementation because parsing is something it can do from data it +// already stores. A host app that wants richer or more current parsing +// than this can run its own library over store.Session.UserAgent, +// which stays exposed verbatim for exactly that reason. +func ParseUserAgent(ua string) Device { + lower := strings.ToLower(strings.TrimSpace(ua)) + if lower == "" { + return Device{} + } + + // Bots and command-line clients are matched first and exit early: + // several of them embed a full browser string (HeadlessChrome, and + // Bingbot's modern UA) and would otherwise be reported as the + // browser they are imitating. + if name, ok := match(lower, botSignatures); ok { + return Device{Browser: name, Form: FormBot} + } + + d := Device{} + d.Browser, _ = match(lower, browserSignatures) + d.OS, _ = match(lower, osSignatures) + + // The generic "…bot"/"…crawler" heuristic runs only when nothing + // browser-shaped matched, because real device names contain "bot" + // — Android UAs from CUBOT handsets being the reason this guard is + // here rather than at the top with the explicit signatures. + if d.Browser == "" && looksLikeBot(lower) { + return Device{Browser: "Bot", Form: FormBot} + } + + d.Form = formFactor(lower, d.OS) + return d +} + +// match returns the display name of the first signature whose needle +// appears in lower. Order within each table is significant and is the +// whole mechanism by which overlapping strings resolve correctly. +func match(lower string, table []signature) (string, bool) { + for _, s := range table { + if strings.Contains(lower, s.needle) { + return s.name, true + } + } + return "", false +} + +type signature struct { + needle string // already lowercase + name string +} + +var botSignatures = []signature{ + {"googlebot", "Googlebot"}, + {"bingbot", "Bingbot"}, + {"slurp", "Yahoo! Slurp"}, + {"duckduckbot", "DuckDuckBot"}, + {"baiduspider", "Baiduspider"}, + {"yandexbot", "YandexBot"}, + {"applebot", "Applebot"}, + {"petalbot", "PetalBot"}, + {"ahrefsbot", "AhrefsBot"}, + {"semrushbot", "SemrushBot"}, + {"facebookexternalhit", "facebookexternalhit"}, + {"twitterbot", "Twitterbot"}, + {"slackbot", "Slackbot"}, + {"discordbot", "Discordbot"}, + {"telegrambot", "TelegramBot"}, + {"headlesschrome", "HeadlessChrome"}, + {"curl/", "curl"}, + {"wget/", "Wget"}, + {"httpie", "HTTPie"}, + {"postmanruntime", "Postman"}, + {"insomnia", "Insomnia"}, + {"python-requests", "python-requests"}, + {"python-urllib", "python-urllib"}, + {"go-http-client", "Go-http-client"}, + {"okhttp", "OkHttp"}, + {"axios/", "axios"}, + {"node-fetch", "node-fetch"}, + {"libwww-perl", "libwww-perl"}, + {"java/", "Java"}, +} + +// Chromium-based browsers all carry "chrome/", and Chrome itself +// carries "safari/", so the specific ones must be checked before the +// generic ones — Edge before Chrome, Chrome before Safari. +var browserSignatures = []signature{ + {"edg/", "Edge"}, + {"edga/", "Edge"}, + {"edgios/", "Edge"}, + {"edge/", "Edge"}, + {"opr/", "Opera"}, + {"opios/", "Opera"}, + {"opera", "Opera"}, + {"samsungbrowser/", "Samsung Internet"}, + {"yabrowser/", "Yandex Browser"}, + {"ucbrowser/", "UC Browser"}, + {"vivaldi", "Vivaldi"}, + {"brave/", "Brave"}, + {"duckduckgo/", "DuckDuckGo"}, + {"crios/", "Chrome"}, + {"chromium/", "Chromium"}, + {"chrome/", "Chrome"}, + {"fxios/", "Firefox"}, + {"firefox/", "Firefox"}, + {"seamonkey/", "SeaMonkey"}, + {"trident/", "Internet Explorer"}, + {"msie", "Internet Explorer"}, + {"safari/", "Safari"}, +} + +// iOS entries precede macOS because an iPhone's UA says "like Mac OS +// X"; Android precedes Linux because an Android UA says "Linux". +var osSignatures = []signature{ + {"windows phone", "Windows Phone"}, + {"windows nt", "Windows"}, + {"windows", "Windows"}, + {"android", "Android"}, + {"cros ", "ChromeOS"}, + {"iphone", "iOS"}, + {"ipad", "iOS"}, + {"ipod", "iOS"}, + {"crios/", "iOS"}, + {"fxios/", "iOS"}, + {"mac os x", "macOS"}, + {"macintosh", "macOS"}, + {"freebsd", "FreeBSD"}, + {"openbsd", "OpenBSD"}, + {"linux", "Linux"}, + {"x11", "Linux"}, +} + +func looksLikeBot(lower string) bool { + for _, needle := range []string{"bot/", "bot ", "crawler", "spider", "scraper", "+http"} { + if strings.Contains(lower, needle) { + return true + } + } + return strings.HasSuffix(lower, "bot") +} + +func formFactor(lower, os string) string { + switch { + case strings.Contains(lower, "ipad"), + strings.Contains(lower, "tablet"), + strings.Contains(lower, "kindle"), + strings.Contains(lower, "playbook"): + return FormTablet + // An Android UA without the "Mobile" token is the conventional + // tablet marker, so this has to be decided before the mobile case + // below rather than after it. + case os == "Android" && !strings.Contains(lower, "mobile"): + return FormTablet + case strings.Contains(lower, "mobile"), + strings.Contains(lower, "iphone"), + strings.Contains(lower, "ipod"), + os == "Windows Phone": + return FormMobile + case os == "Windows", os == "macOS", os == "Linux", os == "ChromeOS", os == "FreeBSD", os == "OpenBSD": + return FormDesktop + default: + return "" + } +} From 8351d8abd9c56d22b0e26b751f24562c707f2473 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 21:59:38 +0100 Subject: [PATCH 2/7] feat: label active sessions with device and location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computed on read from the IP and User-Agent a session already carries, so there is no schema change and no migration — every session ever recorded gets a label the first time ListNamed is called. One geolocator lookup per distinct IP, and an error means "unknown", not a failed listing. --- session/named.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 session/named.go diff --git a/session/named.go b/session/named.go new file mode 100644 index 0000000..4a44de7 --- /dev/null +++ b/session/named.go @@ -0,0 +1,106 @@ +package session + +import ( + "context" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" +) + +// NamedSession is an active session plus a human-readable description +// of where it came from — what a "your devices" settings page shows +// instead of a raw session ID. +// +// It embeds store.PublicSession rather than store.Session, so the +// redacted set of fields (no TokenHash, no FamilyID) is what reaches a +// UI, and the raw IP/UserAgent stay available for a host app that +// wants to render them itself. Device and Location are kept alongside +// Label so a caller can format its own string (or sort/group by OS, +// form factor or country) without re-parsing anything. +type NamedSession struct { + store.PublicSession + Device security.Device + Location security.Location + Label string +} + +// Label composes the display string for a session: "Chrome on Windows +// — San Francisco, CA", or just "Chrome on Windows" when the location +// is unknown, or "Unknown device" when the User-Agent was unparseable +// too. Never returns an empty string. +// +// Exported because the location half is host-supplied: an app that +// resolves richer location data at its own layer, or none at all, can +// still produce labels identical to the engine's. +func Label(device security.Device, location security.Location) string { + name := device.String() + if location.IsZero() { + return name + } + return name + " — " + location.String() +} + +// ListNamed returns a user's active sessions with a device/location +// label attached to each. Same set of sessions as List, in the same +// order — labels are computed on read from the IP and User-Agent the +// session already carries, so nothing is stored, no migration exists +// for this, and every session ever recorded gets a label the moment +// this is called. +// +// geo is optional. Left nil, labels are device-only. Supplied, it is +// asked once per distinct IP in the list (several sessions from one +// address is the normal case) and its answers are used as-is. A +// geolocator error is logged and treated as "location unknown" — the +// listing itself never fails because of it, matching how a breach- +// check error never fails SignUp. +func ListNamed( + ctx context.Context, + sessions store.SessionStore, + geo security.IPGeolocator, + log logger.Logger, + userID string, +) ([]NamedSession, error) { + list, err := List(ctx, sessions, userID) + if err != nil { + return nil, err + } + + located := make(map[string]security.Location, len(list)) + out := make([]NamedSession, 0, len(list)) + for _, s := range list { + device := security.ParseUserAgent(s.UserAgent) + + var location security.Location + // An empty IP is not an address to place, and a repeated one is + // not a second question to ask. The cache is per call, not per + // process: sessions are long-lived but a location can change + // hands, and caching across calls would need an invalidation + // story this feature does not need to have. + if geo != nil && s.IP != "" { + cached, ok := located[s.IP] + if !ok { + resolved, locErr := geo.Locate(ctx, s.IP) + if locErr != nil { + log.Warn("session list: geolocation failed", map[string]string{ + "error": locErr.Error(), + "user_id": userID, + "ip": s.IP, + }) + resolved = security.Location{} + } + cached = resolved + located[s.IP] = cached + } + location = cached + } + + out = append(out, NamedSession{ + PublicSession: s.ToPublic(), + Device: device, + Location: location, + Label: Label(device, location), + }) + } + return out, nil +} From c2b246eadbfb9cd8d938d7bcd24b2e5228d3e3f1 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 22:00:35 +0100 Subject: [PATCH 3/7] feat: expose ListNamedSessions and Config.Geolocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new facade beside ListSessions/ListPublicSessions rather than a change to either — existing callers keep their return types. Geolocator is optional in the same way BreachedPasswordChecker is: unset means the feature quietly does less, never that a call fails. --- config.go | 7 +++++++ cryden.go | 18 ++++++++++++++++++ engine.go | 2 ++ 3 files changed, 27 insertions(+) diff --git a/config.go b/config.go index a04626f..e74c4ec 100644 --- a/config.go +++ b/config.go @@ -96,6 +96,13 @@ type Config struct { // own doc comment); a checker error fails open rather than // blocking the account action. BreachedPasswordChecker security.BreachedPasswordChecker + // Geolocator is optional — only used by ListNamedSessions, to turn + // a session's IP into the location half of its label. Ships no + // implementation (see the type's own doc comment); left nil, labels + // are device-only ("Chrome on Windows") and nothing else changes. A + // geolocator error fails open to "location unknown" rather than + // failing the listing. + Geolocator security.IPGeolocator // Anomalies is optional — set it to turn login anomaly detection // on. Left unset, no detection runs at all and nothing about login // changes; there is no partial or degraded mode. Detection is diff --git a/cryden.go b/cryden.go index d47d683..94e16ea 100644 --- a/cryden.go +++ b/cryden.go @@ -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) { @@ -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 { diff --git a/engine.go b/engine.go index edafd7b..d78be3b 100644 --- a/engine.go +++ b/engine.go @@ -25,6 +25,7 @@ type Engine struct { magicLinkSender notify.MagicLinkSender recoveryCodes store.RecoveryCodeStore breachChecker security.BreachedPasswordChecker + geolocator security.IPGeolocator passwordPolicy security.PasswordPolicy anomalies store.AnomalyStore @@ -114,6 +115,7 @@ func New(cfg Config) (*Engine, error) { magicLinkSender: cfg.MagicLinkSender, recoveryCodes: cfg.RecoveryCodes, breachChecker: cfg.BreachedPasswordChecker, + geolocator: cfg.Geolocator, passwordPolicy: cfg.PasswordPolicy, anomalies: cfg.Anomalies, hasher: hasher, From 27faf53e85b7487bd04e52e8faa571db3114362d Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 22:04:52 +0100 Subject: [PATCH 4/7] test: cover parsing, labels, geolocator wiring The parser is tested against authentic User-Agent strings, since the only real risk in it is that browsers impersonate each other inside the header. The rest covers the promises: one lookup per distinct IP, and a failing geolocator costs a label, never the listing. --- config_test.go | 24 ++++ new_facade_test.go | 100 ++++++++++++++ security/geolocation_test.go | 48 +++++++ security/useragent_test.go | 188 ++++++++++++++++++++++++++ session/named_test.go | 248 +++++++++++++++++++++++++++++++++++ 5 files changed, 608 insertions(+) create mode 100644 security/geolocation_test.go create mode 100644 security/useragent_test.go create mode 100644 session/named_test.go diff --git a/config_test.go b/config_test.go index 33c920d..ee71cd2 100644 --- a/config_test.go +++ b/config_test.go @@ -199,3 +199,27 @@ func TestNew_TheTwoDetectionThresholdsDefaultIndependently(t *testing.T) { t.Errorf("a custom CredentialStuffingThresholds must leave anomaly defaults alone, got %+v", e.anomalyThresholds) } } + +// Geolocation is off until a host supplies an implementation, exactly +// like BreachedPasswordChecker — the engine ships none. +func TestNew_GeolocationIsOffWithoutAnImplementation(t *testing.T) { + e, err := New(validConfig()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.geolocator != nil { + t.Error("expected no IPGeolocator when Config.Geolocator is unset") + } +} + +func TestNew_AcceptsAGeolocator(t *testing.T) { + cfg := validConfig() + cfg.Geolocator = fixedGeolocator{} + e, err := New(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.geolocator == nil { + t.Error("expected Config.Geolocator to reach the engine") + } +} diff --git a/new_facade_test.go b/new_facade_test.go index 66b4e31..ead0f0e 100644 --- a/new_facade_test.go +++ b/new_facade_test.go @@ -2,8 +2,10 @@ package cryden import ( "context" + "errors" "testing" + "github.com/crydensync/cryden/v2/security" "github.com/crydensync/cryden/v2/store" "github.com/crydensync/cryden/v2/store/memory" ) @@ -134,3 +136,101 @@ func TestStore_SearchByType_Memory(t *testing.T) { } } } + +// The User-Agent a real browser would send, so the label under test is +// the one a real session list would show. +const chromeWindowsUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + +// fixedGeolocator is the shape a host app supplies: one Locate call, +// its own data source, no engine involvement. err wins when set, to +// exercise the fail-open path end to end. +type fixedGeolocator struct { + loc security.Location + err error +} + +func (g fixedGeolocator) Locate(_ context.Context, _ string) (security.Location, error) { + return g.loc, g.err +} + +var _ security.IPGeolocator = fixedGeolocator{} + +func seedLoggedInUser(ctx context.Context, e *Engine, t *testing.T, userAgent string) string { + t.Helper() + if _, err := SignUp(ctx, e, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4"); err != nil { + t.Fatalf("signup failed: %v", err) + } + if _, err := Login(ctx, e, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", userAgent); err != nil { + t.Fatalf("login failed: %v", err) + } + u, err := GetUser(ctx, e, "raymondproguy@dev.com") + if err != nil { + t.Fatalf("user lookup failed: %v", err) + } + return u.ID +} + +func TestListNamedSessions_LabelsTheSessionLoginCreated(t *testing.T) { + cfg := validConfig() + cfg.Geolocator = fixedGeolocator{loc: security.Location{City: "San Francisco", Region: "CA"}} + engine, err := New(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ctx := context.Background() + userID := seedLoggedInUser(ctx, engine, t, chromeWindowsUA) + + list, err := ListNamedSessions(ctx, engine, userID) + if err != nil { + t.Fatalf("ListNamedSessions failed: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 named session, got %d", len(list)) + } + if list[0].Label != "Chrome on Windows — San Francisco, CA" { + t.Errorf("unexpected label: %q", list[0].Label) + } + // Nothing was stored to make this label: it came from the IP and + // User-Agent Login already recorded on the session. + if list[0].IP != "1.2.3.4" || list[0].UserAgent != chromeWindowsUA { + t.Errorf("expected the raw values to stay available, got %+v", list[0].PublicSession) + } +} + +func TestListNamedSessions_WorksWithoutAGeolocator(t *testing.T) { + engine, err := New(validConfig()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ctx := context.Background() + userID := seedLoggedInUser(ctx, engine, t, chromeWindowsUA) + + list, err := ListNamedSessions(ctx, engine, userID) + if err != nil { + t.Fatalf("ListNamedSessions failed: %v", err) + } + if list[0].Label != "Chrome on Windows" { + t.Errorf("expected a device-only label with no geolocator configured, got %q", list[0].Label) + } +} + +// The listing is how someone revokes a session they don't recognize, so +// a broken geolocator must never be able to take it away from them. +func TestListNamedSessions_SurvivesAFailingGeolocator(t *testing.T) { + cfg := validConfig() + cfg.Geolocator = fixedGeolocator{err: errors.New("provider unreachable")} + engine, err := New(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ctx := context.Background() + userID := seedLoggedInUser(ctx, engine, t, chromeWindowsUA) + + list, err := ListNamedSessions(ctx, engine, userID) + if err != nil { + t.Fatalf("expected the listing to survive a geolocator error, got %v", err) + } + if len(list) != 1 || list[0].Label != "Chrome on Windows" { + t.Errorf("expected a device-only label, got %+v", list) + } +} diff --git a/security/geolocation_test.go b/security/geolocation_test.go new file mode 100644 index 0000000..a6cf08b --- /dev/null +++ b/security/geolocation_test.go @@ -0,0 +1,48 @@ +package security + +import "testing" + +// Location.String() is the whole contract between the engine and a +// host-supplied geolocator: whatever fields the host fills in are what +// gets printed, in that order, with nothing invented and nothing +// dropped. Granularity is therefore the host's choice, which is why +// there is no case here for "abbreviate the region" or "hide the +// country" — the engine does neither. +func TestLocation_String(t *testing.T) { + cases := []struct { + name string + loc Location + want string + }{ + {"city region country", Location{City: "San Francisco", Region: "CA", Country: "US"}, "San Francisco, CA, US"}, + {"city and region only", Location{City: "San Francisco", Region: "CA"}, "San Francisco, CA"}, + {"city and country only", Location{City: "Berlin", Country: "DE"}, "Berlin, DE"}, + {"country alone is still worth printing", Location{Country: "DE"}, "DE"}, + {"region alone", Location{Region: "Bavaria"}, "Bavaria"}, + {"nothing known", Location{}, ""}, + {"whitespace-only fields are not fields", Location{City: " ", Country: "US"}, "US"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.loc.String(); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestLocation_IsZero(t *testing.T) { + if !(Location{}).IsZero() { + t.Error("the zero Location must report IsZero") + } + // A geolocator that returns padding rather than nothing is still + // returning nothing, and a label must not gain a trailing dash for + // it. + if !(Location{City: " ", Region: "\t"}).IsZero() { + t.Error("whitespace-only fields must count as unknown") + } + if (Location{Country: "US"}).IsZero() { + t.Error("a known country is not a zero Location") + } +} diff --git a/security/useragent_test.go b/security/useragent_test.go new file mode 100644 index 0000000..c3cace2 --- /dev/null +++ b/security/useragent_test.go @@ -0,0 +1,188 @@ +package security + +import "testing" + +// Real strings, not invented ones: the whole risk in this parser is +// that browsers deliberately impersonate each other inside the header +// (Chrome claims Safari, Edge claims Chrome, an iPhone claims Mac OS +// X), so a table of authentic UAs is the only thing that proves the +// signature ordering resolves them the right way round. +func TestParseUserAgent(t *testing.T) { + cases := []struct { + name string + ua string + browser string + os string + form string + label string + }{ + { + name: "chrome on windows", + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + browser: "Chrome", os: "Windows", form: FormDesktop, label: "Chrome on Windows", + }, + { + // Carries "Chrome/" AND "Safari/" — both must lose to Edg/. + name: "edge on windows is not chrome", + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.67", + browser: "Edge", os: "Windows", form: FormDesktop, label: "Edge on Windows", + }, + { + name: "opera on windows is not chrome", + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/109.0.0.0", + browser: "Opera", os: "Windows", form: FormDesktop, label: "Opera on Windows", + }, + { + name: "safari on macos", + ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + browser: "Safari", os: "macOS", form: FormDesktop, label: "Safari on macOS", + }, + { + name: "chrome on macos is not safari", + ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + browser: "Chrome", os: "macOS", form: FormDesktop, label: "Chrome on macOS", + }, + { + // "like Mac OS X" is in the string — iOS has to win anyway. + name: "safari on iphone is ios not macos", + ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + browser: "Safari", os: "iOS", form: FormMobile, label: "Safari on iOS", + }, + { + name: "chrome on iphone", + ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1", + browser: "Chrome", os: "iOS", form: FormMobile, label: "Chrome on iOS", + }, + { + name: "ipad is a tablet", + ua: "Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + browser: "Safari", os: "iOS", form: FormTablet, label: "Safari on iOS", + }, + { + // Android's own UA says "Linux" — Android has to win. + name: "chrome on android phone is not linux", + ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + browser: "Chrome", os: "Android", form: FormMobile, label: "Chrome on Android", + }, + { + // Same string minus the "Mobile" token: that absence is the + // only thing marking an Android tablet. + name: "android without the mobile token is a tablet", + ua: "Mozilla/5.0 (Linux; Android 13; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + browser: "Chrome", os: "Android", form: FormTablet, label: "Chrome on Android", + }, + { + name: "samsung internet on android is not chrome", + ua: "Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36", + browser: "Samsung Internet", os: "Android", form: FormMobile, label: "Samsung Internet on Android", + }, + { + name: "firefox on linux", + ua: "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + browser: "Firefox", os: "Linux", form: FormDesktop, label: "Firefox on Linux", + }, + { + // Also X11, also Chrome — ChromeOS must beat the Linux entry. + name: "chromeos is not linux", + ua: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + browser: "Chrome", os: "ChromeOS", form: FormDesktop, label: "Chrome on ChromeOS", + }, + { + name: "internet explorer 11 advertises neither msie nor a browser name", + ua: "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", + browser: "Internet Explorer", os: "Windows", form: FormDesktop, label: "Internet Explorer on Windows", + }, + { + // An in-app webview: no browser token at all, but the OS is + // still recoverable and still worth showing. + name: "ios webview degrades to the os alone", + ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + browser: "", os: "iOS", form: FormMobile, label: "iOS", + }, + { + name: "curl reports no os", + ua: "curl/8.6.0", + browser: "curl", os: "", form: FormBot, label: "curl", + }, + { + name: "go http client", + ua: "Go-http-client/2.0", + browser: "Go-http-client", os: "", form: FormBot, label: "Go-http-client", + }, + { + name: "googlebot", + ua: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + browser: "Googlebot", os: "", form: FormBot, label: "Googlebot", + }, + { + // Headless Chrome embeds a complete Chrome UA; the explicit + // signature has to be checked before the browser table. + name: "headless chrome is a bot", + ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/124.0.0.0 Safari/537.36", + browser: "HeadlessChrome", os: "", form: FormBot, label: "HeadlessChrome", + }, + { + name: "an unrecognized crawler still reads as a bot", + ua: "SomeRandomCrawler/1.0 (+http://example.com/about)", + browser: "Bot", os: "", form: FormBot, label: "Bot", + }, + { + // The reason the generic bot heuristic runs last: CUBOT is a + // real handset brand, and this is a person's phone. + name: "a cubot handset is a phone not a bot", + ua: "Mozilla/5.0 (Linux; Android 11; CUBOT NOTE 20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.74 Mobile Safari/537.36", + browser: "Chrome", os: "Android", form: FormMobile, label: "Chrome on Android", + }, + { + name: "no user agent at all", + ua: "", + browser: "", os: "", form: "", label: "Unknown device", + }, + { + name: "unparseable junk", + ua: "??? ???", + browser: "", os: "", form: "", label: "Unknown device", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseUserAgent(tc.ua) + if got.Browser != tc.browser { + t.Errorf("Browser: got %q, want %q", got.Browser, tc.browser) + } + if got.OS != tc.os { + t.Errorf("OS: got %q, want %q", got.OS, tc.os) + } + if got.Form != tc.form { + t.Errorf("Form: got %q, want %q", got.Form, tc.form) + } + if got.String() != tc.label { + t.Errorf("String(): got %q, want %q", got.String(), tc.label) + } + }) + } +} + +// Nothing guarantees a client sends the canonical casing, and every +// signature in the tables is lowercase. +func TestParseUserAgent_IsCaseInsensitive(t *testing.T) { + got := ParseUserAgent("MOZILLA/5.0 (WINDOWS NT 10.0; WIN64; X64) APPLEWEBKIT/537.36 (KHTML, LIKE GECKO) CHROME/124.0.0.0 SAFARI/537.36") + if got.Browser != "Chrome" || got.OS != "Windows" { + t.Errorf("expected Chrome on Windows regardless of casing, got %+v", got) + } +} + +// A session list has a row to fill for every session, including one +// created by a client that sent nothing. +func TestDevice_StringIsNeverEmpty(t *testing.T) { + if (Device{}).String() != "Unknown device" { + t.Errorf("expected a printable fallback, got %q", (Device{}).String()) + } + if !(Device{}).IsZero() { + t.Error("expected the zero Device to report IsZero") + } + if ParseUserAgent("curl/8.6.0").IsZero() { + t.Error("expected a recognized client not to report IsZero") + } +} diff --git a/session/named_test.go b/session/named_test.go new file mode 100644 index 0000000..beebd15 --- /dev/null +++ b/session/named_test.go @@ -0,0 +1,248 @@ +package session + +import ( + "context" + "errors" + "testing" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" +) + +const ( + chromeWindows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + safariIPhone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1" +) + +// stubGeolocator stands in for the implementation a host app supplies. +// It counts calls because "ask once per distinct IP" is a real promise +// this package makes to whoever pays for those lookups. +type stubGeolocator struct { + byIP map[string]security.Location + err error + calls int + seen []string +} + +func (g *stubGeolocator) Locate(_ context.Context, ip string) (security.Location, error) { + g.calls++ + g.seen = append(g.seen, ip) + if g.err != nil { + return security.Location{}, g.err + } + return g.byIP[ip], nil +} + +var _ security.IPGeolocator = (*stubGeolocator)(nil) + +func TestListNamed_LabelsDeviceAndLocation(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ + ID: "s1", FamilyID: "s1", UserID: "user-1", + IP: "1.2.3.4", UserAgent: chromeWindows, TokenHash: "hash-1", + }) + geo := &stubGeolocator{byIP: map[string]security.Location{ + "1.2.3.4": {City: "San Francisco", Region: "CA"}, + }} + + list, err := ListNamed(ctx, sessions, geo, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 named session, got %d", len(list)) + } + + got := list[0] + if got.Label != "Chrome on Windows — San Francisco, CA" { + t.Errorf("unexpected label: %q", got.Label) + } + // The parsed parts travel alongside the label so a UI can group or + // sort by them without parsing the string back apart. + if got.Device.Browser != "Chrome" || got.Device.OS != "Windows" || got.Device.Form != security.FormDesktop { + t.Errorf("unexpected device: %+v", got.Device) + } + if got.Location.City != "San Francisco" { + t.Errorf("unexpected location: %+v", got.Location) + } + // The embedded PublicSession is what makes this usable as an + // HTTP-facing DTO on its own: the raw values stay available, the + // secret ones were never there. + if got.ID != "s1" || got.IP != "1.2.3.4" || got.UserAgent != chromeWindows { + t.Errorf("expected the public session fields to be carried through, got %+v", got.PublicSession) + } + if got.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be carried through") + } +} + +// A host app with no geolocator configured is the default case, not a +// degraded one — half a label is the whole feature for them. +func TestListNamed_WithoutAGeolocatorLabelsAreDeviceOnly(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ + ID: "s1", FamilyID: "s1", UserID: "user-1", + IP: "1.2.3.4", UserAgent: safariIPhone, + }) + + list, err := ListNamed(ctx, sessions, nil, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if list[0].Label != "Safari on iOS" { + t.Errorf("expected a device-only label, got %q", list[0].Label) + } + if !list[0].Location.IsZero() { + t.Errorf("expected no location without a geolocator, got %+v", list[0].Location) + } +} + +// Someone's phone and laptop behind one home address is the normal +// shape of this list, and the host pays per lookup. +func TestListNamed_AsksTheGeolocatorOncePerDistinctIP(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "user-1", IP: "1.2.3.4", UserAgent: chromeWindows}) + sessions.Create(ctx, store.Session{ID: "s2", FamilyID: "s2", UserID: "user-1", IP: "1.2.3.4", UserAgent: safariIPhone}) + sessions.Create(ctx, store.Session{ID: "s3", FamilyID: "s3", UserID: "user-1", IP: "5.6.7.8", UserAgent: chromeWindows}) + geo := &stubGeolocator{byIP: map[string]security.Location{ + "1.2.3.4": {City: "San Francisco", Region: "CA"}, + "5.6.7.8": {City: "Berlin", Country: "DE"}, + }} + + list, err := ListNamed(ctx, sessions, geo, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(list) != 3 { + t.Fatalf("expected 3 named sessions, got %d", len(list)) + } + if geo.calls != 2 { + t.Errorf("expected 2 lookups for 2 distinct addresses, got %d (%v)", geo.calls, geo.seen) + } + // The cached answer must reach the second session too, not just the + // one that paid for the lookup. + for _, s := range list { + if s.IP == "1.2.3.4" && s.Location.City != "San Francisco" { + t.Errorf("session %s missed the cached location: %+v", s.ID, s.Location) + } + } +} + +// Fails open, same as a breach-check error never failing SignUp: a +// third-party provider being down must not stop someone seeing the +// devices on their own account — that list is how they revoke an +// attacker's session. +func TestListNamed_GeolocatorErrorDegradesToDeviceOnly(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "user-1", IP: "1.2.3.4", UserAgent: chromeWindows}) + sessions.Create(ctx, store.Session{ID: "s2", FamilyID: "s2", UserID: "user-1", IP: "1.2.3.4", UserAgent: chromeWindows}) + geo := &stubGeolocator{err: errors.New("provider unreachable")} + + list, err := ListNamed(ctx, sessions, geo, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("a geolocator failure must not fail the listing: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected both sessions, got %d", len(list)) + } + for _, s := range list { + if s.Label != "Chrome on Windows" { + t.Errorf("expected a device-only label after a lookup failure, got %q", s.Label) + } + } + // A failure is cached like any other answer — a broken provider gets + // asked once, not once per session. + if geo.calls != 1 { + t.Errorf("expected the failed lookup not to be retried per session, got %d calls", geo.calls) + } +} + +func TestListNamed_EmptyIPIsNeverLookedUp(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "user-1", UserAgent: chromeWindows}) + geo := &stubGeolocator{byIP: map[string]security.Location{"": {City: "Nowhere"}}} + + list, err := ListNamed(ctx, sessions, geo, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if geo.calls != 0 { + t.Errorf("an empty IP is not an address to place, got %d calls", geo.calls) + } + if list[0].Label != "Chrome on Windows" { + t.Errorf("expected a device-only label, got %q", list[0].Label) + } +} + +// Every session gets a printable row, including one from a client that +// sent no User-Agent at all. +func TestListNamed_UnknownDeviceStillGetsALabel(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "user-1", IP: "5.6.7.8"}) + geo := &stubGeolocator{byIP: map[string]security.Location{"5.6.7.8": {City: "Berlin", Country: "DE"}}} + + list, err := ListNamed(ctx, sessions, geo, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if list[0].Label != "Unknown device — Berlin, DE" { + t.Errorf("unexpected label: %q", list[0].Label) + } +} + +// ListNamed is a labelled List, not a second query with its own rules: +// a caller who switches to it must not silently start seeing revoked +// sessions or other people's. +func TestListNamed_ExcludesRevokedSessionsAndOtherUsers(t *testing.T) { + ctx := context.Background() + sessions := memory.NewSessionStore() + sessions.Create(ctx, store.Session{ID: "s1", FamilyID: "s1", UserID: "user-1", IP: "1.2.3.4", UserAgent: chromeWindows}) + sessions.Create(ctx, store.Session{ID: "s2", FamilyID: "s2", UserID: "user-1", IP: "1.2.3.4", UserAgent: chromeWindows}) + sessions.Create(ctx, store.Session{ID: "s3", FamilyID: "s3", UserID: "user-2", IP: "9.9.9.9", UserAgent: chromeWindows}) + sessions.Revoke(ctx, "s2") + + list, err := ListNamed(ctx, sessions, nil, noopLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(list) != 1 || list[0].ID != "s1" { + t.Fatalf("expected only the active session for user-1, got %+v", list) + } + + empty, err := ListNamed(ctx, sessions, nil, noopLogger{}, "nobody") + if err != nil { + t.Fatalf("an unknown user should not be an error: %v", err) + } + if len(empty) != 0 { + t.Fatalf("expected no sessions for an unknown user, got %d", len(empty)) + } +} + +func TestLabel(t *testing.T) { + device := security.Device{Browser: "Firefox", OS: "Linux", Form: security.FormDesktop} + cases := []struct { + name string + device security.Device + location security.Location + want string + }{ + {"both halves", device, security.Location{City: "Lagos", Country: "NG"}, "Firefox on Linux — Lagos, NG"}, + {"no location", device, security.Location{}, "Firefox on Linux"}, + {"no device", security.Device{}, security.Location{Country: "NG"}, "Unknown device — NG"}, + {"neither", security.Device{}, security.Location{}, "Unknown device"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Label(tc.device, tc.location); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} From 2abe41d1cefea412dee0f231b25aad123e62e55f Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 22:09:01 +0100 Subject: [PATCH 5/7] test: add named-sessions smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 42 checks over seven scenarios, including the two that matter most: a down geolocator costs a label and never the listing, and a session recorded before any geolocator existed gets a located label the moment one is configured — nothing is stored, so nothing needs backfilling. --- cmd/smoketest/named-sessions/main.go | 425 +++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 cmd/smoketest/named-sessions/main.go diff --git a/cmd/smoketest/named-sessions/main.go b/cmd/smoketest/named-sessions/main.go new file mode 100644 index 0000000..2287f14 --- /dev/null +++ b/cmd/smoketest/named-sessions/main.go @@ -0,0 +1,425 @@ +// Command named-sessions is a standalone, no-database smoke test for +// named/fingerprinted sessions: the label a "your devices" page shows +// for each active session, one geolocation lookup per distinct address, +// what happens when no geolocator is configured or the one configured +// fails, bots and clients that send no User-Agent at all, and the +// property the design rests on — labels are computed on read, so a +// session recorded before any of this existed still gets one. Run with: +// +// go run ./cmd/smoketest/named-sessions +package main + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store/memory" +) + +const ( + email = "raymondproguy@dev.com" + password = "Tr0ubl3-Fr33!2026" + + homeIP = "1.2.3.4" + officeIP = "203.0.113.7" + + // Authentic strings, because the labels below are only meaningful + // if the input is what a real client actually sends. + chromeWindows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + safariIPhone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1" + firefoxLinux = "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0" + curlAgent = "curl/8.6.0" +) + +var failures int + +// countingGeolocator is the implementation a host app would supply, +// plus a call counter — "asked once per distinct IP" is a promise the +// host pays for, so it is worth checking from the outside. +type countingGeolocator struct { + byIP map[string]security.Location + err error + calls int +} + +func (g *countingGeolocator) Locate(_ context.Context, ip string) (security.Location, error) { + g.calls++ + if g.err != nil { + return security.Location{}, g.err + } + return g.byIP[ip], nil +} + +func knownAddresses() *countingGeolocator { + return &countingGeolocator{byIP: map[string]security.Location{ + homeIP: {City: "San Francisco", Region: "CA"}, + officeIP: {City: "Berlin", Country: "DE"}, + }} +} + +// rig is one isolated engine plus the pieces the checks read back from. +// The stores are held so one scenario can build a second engine over +// the same data. +type rig struct { + engine *cryden.Engine + users *memory.UserStore + sessions *memory.SessionStore + geo *countingGeolocator + userID string +} + +func newRig(ctx context.Context, geo *countingGeolocator) (*rig, error) { + r := &rig{ + users: memory.NewUserStore(), + sessions: memory.NewSessionStore(), + geo: geo, + } + cfg := cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: r.users, + Sessions: r.sessions, + Audit: memory.NewAuditStore(), + // Several logins from one address in a row is the normal shape of + // this test, not an attack. + RateLimitAttempts: 1000, + } + // Assigned only when non-nil: a typed nil pointer in an interface + // field is not a nil interface, and would be called anyway. + if geo != nil { + cfg.Geolocator = geo + } + engine, err := cryden.New(cfg) + if err != nil { + return nil, err + } + r.engine = engine + + user, err := cryden.SignUp(ctx, engine, email, password, homeIP) + if err != nil { + return nil, err + } + r.userID = user.ID + return r, nil +} + +func (r *rig) login(ctx context.Context, ip, userAgent string) error { + _, err := cryden.Login(ctx, r.engine, email, password, ip, userAgent) + return err +} + +func (r *rig) named(ctx context.Context) []cryden.NamedSession { + list, err := cryden.ListNamedSessions(ctx, r.engine, r.userID) + if err != nil { + fail(fmt.Sprintf("listing named sessions: %v", err)) + return nil + } + return list +} + +// labels returns every session's label, sorted, so a scenario can +// assert on the set without depending on store ordering. +func (r *rig) labels(ctx context.Context) []string { return labelsOf(r.named(ctx)) } + +// labelsOf is the same over a listing already in hand — which matters +// wherever lookup counts are being checked, since the geolocation cache +// is per call and a second listing legitimately asks again. +func labelsOf(list []cryden.NamedSession) []string { + out := make([]string, 0, len(list)) + for _, s := range list { + out = append(out, s.Label) + } + sort.Strings(out) + return out +} + +func main() { + ctx := context.Background() + + labelsARealLogin(ctx) + severalDevicesOneAccount(ctx) + withoutAGeolocator(ctx) + failingGeolocator(ctx) + unrecognizedClients(ctx) + revokedSessionsDisappear(ctx) + labelsAreComputedNotStored(ctx) + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + return + } + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) +} + +// The straight-line case: one login, one labelled row. +func labelsARealLogin(ctx context.Context) { + section("a login from a browser gets a device and a location") + r, err := newRig(ctx, knownAddresses()) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in from a Chrome-on-Windows browser", r.login(ctx, homeIP, chromeWindows)) + + list := r.named(ctx) + expectCount("one active session is listed", len(list), 1) + if len(list) != 1 { + return + } + s := list[0] + expectString("its label reads as a person would recognize it", s.Label, "Chrome on Windows — San Francisco, CA") + expectString("the browser was parsed out", s.Device.Browser, "Chrome") + expectString("the OS was parsed out", s.Device.OS, "Windows") + expectString("the form factor was parsed out", s.Device.Form, security.FormDesktop) + expectString("the city came from the host's geolocator", s.Location.City, "San Francisco") + expectString("the region came with it", s.Location.Region, "CA") + + // The raw values stay exposed: the label is an addition, not a + // replacement, and a host app can render its own. + expectString("the raw IP is still available", s.IP, homeIP) + expectString("the raw User-Agent is still available", s.UserAgent, chromeWindows) + if s.ID == "" { + fail("the session ID is needed to revoke this row and was empty") + } else { + pass("the session ID is present, so the row is actionable") + } + if s.CreatedAt.IsZero() { + fail("CreatedAt was zero, so the row cannot be sorted by recency") + } else { + pass("CreatedAt came through") + } + expectCount("the geolocator was asked exactly once", r.geo.calls, 1) +} + +// Two devices at home, one at the office: the realistic list, and the +// one that proves lookups are per address rather than per session. +func severalDevicesOneAccount(ctx context.Context) { + section("several devices on one account") + r, err := newRig(ctx, knownAddresses()) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in from the laptop at home", r.login(ctx, homeIP, chromeWindows)) + check("logged in from the phone on the same connection", r.login(ctx, homeIP, safariIPhone)) + check("logged in from a Linux machine at the office", r.login(ctx, officeIP, firefoxLinux)) + + list := r.named(ctx) + expectLabels("all three sessions are labelled distinctly", labelsOf(list), + "Chrome on Windows — San Francisco, CA", + "Firefox on Linux — Berlin, DE", + "Safari on iOS — San Francisco, CA", + ) + expectCount("two distinct addresses cost two lookups, not three", r.geo.calls, 2) + + // Form factor is what a UI groups by ("phones", "computers"), so + // check it survived on the phone specifically. + for _, s := range list { + if s.Device.OS == "iOS" { + expectString("the phone is marked as a mobile device", s.Device.Form, security.FormMobile) + } + } +} + +// No geolocator configured is the default, and half a label is the +// whole feature for a host that never wires one up. +func withoutAGeolocator(ctx context.Context) { + section("no geolocator configured") + r, err := newRig(ctx, nil) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in from the laptop", r.login(ctx, homeIP, chromeWindows)) + check("logged in from the phone", r.login(ctx, homeIP, safariIPhone)) + + expectLabels("labels are device-only and still useful", r.labels(ctx), + "Chrome on Windows", + "Safari on iOS", + ) + for _, s := range r.named(ctx) { + if !s.Location.IsZero() { + fail(fmt.Sprintf("expected no location without a geolocator, got %+v", s.Location)) + return + } + } + pass("no location was invented for any session") +} + +// Negative case, and the important one: this listing is how someone +// revokes a session they do not recognize, so a broken third-party +// provider must not be able to take it away from them. +func failingGeolocator(ctx context.Context) { + section("the host's geolocator is down") + geo := knownAddresses() + geo.err = fmt.Errorf("geo provider unreachable") + r, err := newRig(ctx, geo) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in from the laptop", r.login(ctx, homeIP, chromeWindows)) + check("logged in from the phone on the same connection", r.login(ctx, homeIP, safariIPhone)) + + list := r.named(ctx) + expectCount("the listing still returns every session", len(list), 2) + expectLabels("labels degrade to their device half", labelsOf(list), + "Chrome on Windows", + "Safari on iOS", + ) + expectCount("one listing asked once, not once per session on the address", geo.calls, 1) +} + +// A client that sends nothing, and one that is plainly not a person's +// browser. Both must produce an honest row rather than a blank or a +// guess. +func unrecognizedClients(ctx context.Context) { + section("clients that are not a browser") + r, err := newRig(ctx, knownAddresses()) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in sending no User-Agent at all", r.login(ctx, homeIP, "")) + check("logged in from a command-line client", r.login(ctx, homeIP, curlAgent)) + + expectLabels("both get an honest, printable label", r.labels(ctx), + "Unknown device — San Francisco, CA", + "curl — San Francisco, CA", + ) + for _, s := range r.named(ctx) { + if s.Device.Browser == "curl" { + expectString("the command-line client is marked as a bot, not a browser", s.Device.Form, security.FormBot) + expectString("and no OS is claimed for it", s.Device.OS, "") + } + } +} + +// Revoking is the action the whole list exists for, so the revoked row +// must actually leave it. +func revokedSessionsDisappear(ctx context.Context) { + section("revoking the session you do not recognize") + r, err := newRig(ctx, knownAddresses()) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in from the laptop", r.login(ctx, homeIP, chromeWindows)) + check("logged in from an unfamiliar machine at another address", r.login(ctx, officeIP, firefoxLinux)) + + list := r.named(ctx) + expectCount("both sessions are listed", len(list), 2) + + var target string + for _, s := range list { + if s.Location.City == "Berlin" { + target = s.ID + } + } + if target == "" { + fail("could not find the unfamiliar session to revoke") + return + } + check("revoked it by the ID from its own row", cryden.RevokeSession(ctx, r.engine, target, r.userID)) + expectLabels("only the familiar session remains", r.labels(ctx), + "Chrome on Windows — San Francisco, CA", + ) +} + +// The design claim, checked end to end: nothing about a label is +// stored, so a session recorded by an engine with no geolocator gets a +// located label the moment one is configured — no migration, no +// backfill, no re-login. +func labelsAreComputedNotStored(ctx context.Context) { + section("labels are computed on read, never stored") + r, err := newRig(ctx, nil) + if err != nil { + fail(fmt.Sprintf("building the engine: %v", err)) + return + } + check("logged in while no geolocator was configured", r.login(ctx, homeIP, chromeWindows)) + expectLabels("the label has no location, as expected", r.labels(ctx), "Chrome on Windows") + + // A second engine over the same stores — what deploying a + // geolocator later actually looks like. + geo := knownAddresses() + upgraded, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: r.users, + Sessions: r.sessions, + Audit: memory.NewAuditStore(), + Geolocator: geo, + RateLimitAttempts: 1000, + }) + if err != nil { + fail(fmt.Sprintf("building the upgraded engine: %v", err)) + return + } + list, err := cryden.ListNamedSessions(ctx, upgraded, r.userID) + if err != nil { + fail(fmt.Sprintf("listing from the upgraded engine: %v", err)) + return + } + expectCount("the same stored session is still there", len(list), 1) + if len(list) != 1 { + return + } + expectString("and now carries a location, with nothing re-recorded", list[0].Label, + "Chrome on Windows — San Francisco, CA") +} + +func section(name string) { + fmt.Printf("\n— %s\n", name) +} + +func expectLabels(step string, got []string, want ...string) { + if len(got) != len(want) { + fail(fmt.Sprintf("%s: expected %d label(s) %v, got %d %v", step, len(want), want, len(got), got)) + return + } + for i := range want { + if got[i] != want[i] { + fail(fmt.Sprintf("%s: label %d was %q, want %q", step, i+1, got[i], want[i])) + return + } + } + pass(step) +} + +func expectString(step, got, want string) { + if got != want { + fail(fmt.Sprintf("%s: got %q, want %q", step, got, want)) + return + } + pass(step) +} + +func expectCount(step string, got, want int) { + if got != want { + fail(fmt.Sprintf("%s: got %d, want %d", step, got, want)) + return + } + pass(step) +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +} From 8614a7b26e959fc8d033422cc715136c653b5fa9 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 22:10:20 +0100 Subject: [PATCH 6/7] docs: add named-sessions manual test guide Covers the interface contract a host implements, the User-Agent traps worth checking by hand (browsers impersonate each other in the header), and the upgrade check that matters: labels are computed on read, so existing sessions gain them with no migration. --- docs/testing/named-sessions.md | 249 +++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/testing/named-sessions.md diff --git a/docs/testing/named-sessions.md b/docs/testing/named-sessions.md new file mode 100644 index 0000000..3aaa31d --- /dev/null +++ b/docs/testing/named-sessions.md @@ -0,0 +1,249 @@ +# Manual test guide — named/fingerprinted sessions + +A session list that shows `01a06e3f-9331-707a-b540-9854bf6d5f76` asks +someone to recognize a UUID. `ListNamedSessions` shows +**"Chrome on Windows — San Francisco, CA"** instead, so the row they +don't recognize is the row they revoke. + +Two things make up a label: + +- the **device** half, parsed from the `User-Agent` the session already + carries. Pure string matching, no network call, always available. +- the **location** half, resolved from the session's IP by a + `security.IPGeolocator` the **host app supplies**. The engine ships + zero implementations of it, for the same reason it ships none of + `BreachedPasswordChecker`: placing an address means an outbound call + or a licensed database, and the engine does neither on its own + initiative. + +Both halves are computed **on read**. Nothing is stored, there is **no +migration for this feature**, and every session ever recorded gets a +label the first time you call it — including sessions created before +this code existed. + +The fastest full check is the smoke test: + +``` +go run ./cmd/smoketest/named-sessions +``` + +42 checks over seven scenarios, no database required. What follows is the +same ground by hand. + +## Setup + +Nothing is required. With no geolocator configured, labels are +device-only and everything else behaves identically: + +```go +engine, _ := cryden.New(cryden.Config{ + JWTSecret: os.Getenv("CRYDEN_JWT_SECRET"), + Users: users, + Sessions: sessions, + Audit: audit, +}) +``` + +To get the location half, implement the one-method interface against +whatever you already have — a geo-IP database on disk, a provider API, +or the country header your CDN already puts on the request: + +```go +type cdnHeaderGeolocator struct{ byIP *lru.Cache } + +func (g cdnHeaderGeolocator) Locate(ctx context.Context, ip string) (security.Location, error) { + v, ok := g.byIP.Get(ip) + if !ok { + // Unplaceable is not an error: private ranges, carrier NAT and + // addresses you have no data for are all normal. + return security.Location{}, nil + } + return v.(security.Location), nil +} + +engine, _ := cryden.New(cryden.Config{ + // ... + Geolocator: cdnHeaderGeolocator{byIP: cache}, +}) +``` + +Two things to know about the contract: + +- **Return the zero `Location` with a nil error** for an address you + simply can't place. Return an error only when the lookup itself + failed. +- **Granularity is yours.** `Location.String()` joins the non-empty + fields with `", "` and does nothing else — fill in `City`+`Region` + and labels read "San Francisco, CA"; add `Country` and they read + "San Francisco, CA, US". The engine never abbreviates, expands or + invents a field. + +## Reading the results + +```go +list, err := cryden.ListNamedSessions(ctx, engine, userID) +``` + +Each element embeds `store.PublicSession` (so `ID`, `IP`, `UserAgent`, +`CreatedAt` are all right there, and `TokenHash`/`FamilyID` are not) +plus: + +| Field | Example | Use | +|---|---|---| +| `Label` | `"Chrome on Windows — San Francisco, CA"` | print it | +| `Device.Browser` | `"Chrome"` | group/sort | +| `Device.OS` | `"Windows"` | group/sort | +| `Device.Form` | `"desktop"`, `"mobile"`, `"tablet"`, `"bot"` | icons, grouping | +| `Location.City` / `.Region` / `.Country` | `"San Francisco"` / `"CA"` / `""` | your own formatting | + +`Label` is never empty. The fallbacks, in order: + +| Known | Label | +|---|---| +| device + location | `Chrome on Windows — San Francisco, CA` | +| device only | `Chrome on Windows` | +| OS only (in-app webviews) | `iOS — Berlin, DE` | +| neither | `Unknown device` | + +`session.Label(device, location)` is exported if you resolve location at +your own layer and want strings identical to the engine's. + +## 1. A browser login gets both halves + +Log in from a real browser with a geolocator configured that knows the +address, then list: + +``` +Chrome on Windows — San Francisco, CA +``` + +Check `Device.Form == "desktop"`, `Location.City == "San Francisco"`, +and that `IP`/`UserAgent` are still the raw recorded values — the label +is an addition, not a replacement. + +## 2. Several devices, one account + +Log in from a laptop and a phone **on the same connection**, then from +somewhere else: + +``` +Chrome on Windows — San Francisco, CA +Safari on iOS — San Francisco, CA +Firefox on Linux — Berlin, DE +``` + +The thing to verify here is the **lookup count**: two distinct addresses +across three sessions is **two** `Locate` calls, not three. Log inside +your geolocator and count them. The cache is per call, so a second +`ListNamedSessions` legitimately asks again — a session's location can +change hands, and caching across calls would need an invalidation story +this feature doesn't need to have. + +## 3. No geolocator configured + +Build the engine without `Geolocator` and list the same sessions: + +``` +Chrome on Windows +Safari on iOS +``` + +Every `Location` must be the zero value. Nothing invented, nothing +guessed, no trailing dash. + +## 4. The geolocator is down + +Make `Locate` return an error for every address. Then: + +- the listing still **succeeds**; +- every session is still returned; +- labels degrade to their device half; +- a warning is logged (`session list: geolocation failed`, with the IP); +- the failure is cached like any other answer, so one listing asks + **once**, not once per session. + +This is the important negative case. That list is how someone revokes +an attacker's session — a third-party provider's uptime must never be +able to take it away from them, exactly as a breach-check failure never +blocks `SignUp`. + +## 5. Clients that aren't a browser + +Log in sending no `User-Agent` at all, and again from `curl`: + +``` +Unknown device — San Francisco, CA +curl — San Francisco, CA +``` + +`curl` must come back with `Device.Form == "bot"` and an **empty** +`Device.OS` — "curl on Linux" would be a device claim the string can't +support. Same for `Googlebot`, `HeadlessChrome`, `Go-http-client` and +friends. + +Worth trying a handful of authentic strings by hand, because the traps +are all in the header itself: + +| Sent by | Label | Why it's a trap | +|---|---|---| +| Edge | `Edge on Windows` | its UA contains both `Chrome/` and `Safari/` | +| Chrome | `Chrome on Windows` | its UA contains `Safari/` | +| iPhone Safari | `Safari on iOS` | its UA contains `Mac OS X` | +| Android Chrome | `Chrome on Android` | its UA contains `Linux` | +| ChromeOS | `Chrome on ChromeOS` | its UA contains `X11` | +| Android tablet | form `tablet` | the *absence* of `Mobile` is the only marker | +| a CUBOT handset | `Chrome on Android` | the model name contains "bot" | + +## 6. Revoking from the list + +List, take the `ID` off the row you don't recognize, and pass it to +`cryden.RevokeSession`. Re-list: that row is gone, the others remain. +`ListNamedSessions` is a labelled `ListSessions`, so revoked sessions +and other users' sessions are excluded by exactly the same rules. + +## 7. Labels are computed, not stored + +The one to check if you're upgrading an existing deployment. Log in with +**no** geolocator configured, then build a second engine over the **same +stores** with one configured, and list again: + +``` +before: Chrome on Windows +after: Chrome on Windows — San Francisco, CA +``` + +Same stored session, no re-login, no backfill, no migration. This is +also what makes the device parser safe to improve later: labels get +better on the next read, retroactively, for sessions that were recorded +years earlier. + +## Postgres + +There is nothing new to run. This feature adds **no table, no column and +no query** — it reads `IP` and `UserAgent` off the sessions +`ListByUser` already returns. If `cryden.ListSessions` works against +your Postgres deployment, so does this. + +## Known limits + +- **A `User-Agent` is unauthenticated, client-supplied text.** Anything + can send anything. Treat a label as a recognition aid for a human + reading a list, never as identity, never as a device fingerprint in + the security sense, and never as an input to an access decision. +- **The parser is a fixed table and will age.** A browser released after + this code degrades to its OS, or to `Unknown device`. That is a + cosmetic regression, never a functional one, and the raw `UserAgent` + stays exposed so a host app can run its own library over it instead. +- **No version numbers in labels.** "Chrome 124 on Windows 11" churns on + every browser release and helps nobody pick their own laptop out of a + list of two. +- **"Named" means engine-derived, not user-editable.** There is no + nickname field: a host app that wants "Ray's work laptop" stores that + itself, keyed by session ID. The engine deliberately keeps no + user-supplied display string. +- **Location accuracy is entirely the host's.** VPNs, carrier NAT, + corporate egress and CDN edges all mean the coarse place shown may be + nowhere near the person. This is also why the label is informational + and nothing in the engine acts on it. +- **The engine still ships no geolocator.** If you find one bundled, + that's a regression against the rule this feature was built under. From 345b2d73c77b4377609fb64040c04cecd4c195e7 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Fri, 4 Sep 2026 22:19:40 +0100 Subject: [PATCH 7/7] docs: mark named sessions done, queue item 11 Item 10's labels are computed on read, so the branch note also records that it carries items 8 and 9 and how it would separate from them. --- docs/development/CURRENT-STATE.md | 57 ++++++++++++++++++++--- docs/development/NEXT.md | 48 +++++--------------- docs/development/PROGRESS.md | 75 +++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 43 deletions(-) diff --git a/docs/development/CURRENT-STATE.md b/docs/development/CURRENT-STATE.md index 1f4803e..bcc4d84 100644 --- a/docs/development/CURRENT-STATE.md +++ b/docs/development/CURRENT-STATE.md @@ -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 @@ -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` @@ -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. @@ -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 @@ -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 diff --git a/docs/development/NEXT.md b/docs/development/NEXT.md index d3610c1..0665ad6 100644 --- a/docs/development/NEXT.md +++ b/docs/development/NEXT.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/docs/development/PROGRESS.md b/docs/development/PROGRESS.md index 03a5aec..502af93 100644 --- a/docs/development/PROGRESS.md +++ b/docs/development/PROGRESS.md @@ -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.