Release 0.11.0: trust device for 30 days

This commit is contained in:
Ben Stull
2026-05-28 03:39:28 -07:00
parent 7872b921ed
commit 6fb68a95c7
14 changed files with 1600 additions and 29 deletions
+75
View File
@@ -0,0 +1,75 @@
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
--
-- After a successful OTC or passcode sign-in, the user can check
-- "trust this device for 30 days." The framework then issues a
-- server-issued opaque device-trust token, stores its hash on this
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
-- cookie carrying the raw token. On a subsequent visit, the cookie is
-- presented at `/auth/device-trust/start`; if the server can match the
-- hash to a non-expired non-revoked row, the user is signed in without
-- another OTC / passcode round-trip.
--
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
-- in `backend/app/device_trust.py`; raising or lowering it (or making
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
-- session-revocation surface this table will eventually share with the
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the revoke-device UI address a single
-- row by id without leaking the token shape.
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
-- user automatically loses every trusted device.
-- * `device_token_hash` — bcrypt hash of the random opaque token
-- issued at trust-time. The raw token only ever lives in the
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
-- server-side storage is the hash, so a DB compromise does not
-- hand attackers a stash of valid device tokens.
-- * `created_at` — when the row was issued.
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
-- is dead; the lookup path refuses it without further checks.
-- * `user_agent` — the User-Agent header captured at issuance.
-- Stored verbatim (truncated to 1024 chars at the application
-- layer) so the revoke-device UI can show a rough device label.
-- Not used for any auth decision — purely a hint to the user
-- reviewing their device list.
-- * `last_seen_at` — refreshed every time the row authenticates a
-- request. Lets the revoke-device UI surface "last used 3 days
-- ago" so the user can tell which row corresponds to which
-- device.
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
-- (or admin) revoked the row. Lookups treat any non-NULL value
-- as "this row is dead" without consulting the expiry; the
-- revoke gesture is intentionally one-way (a revoked device must
-- re-trust to come back online).
--
-- Indexing: a unique index on `device_token_hash` so collisions are
-- detectable at insert time (the token space is 256 bits of CSPRNG
-- entropy, so a collision is structurally impossible, but the
-- declaration documents the invariant). A separate index on
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
-- a covering walk.
--
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
--
-- The cookie shape: `rfc_device_trust` carries the raw token,
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
-- authentication, not analytics), so it is set regardless of the
-- user's analytics / other-cookies choices.
CREATE TABLE device_trust (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_token_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT
);
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);