~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Web logins from before cutover carry over, until a set date

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:web/carryover

What changes

Web users stay logged in across cutover, for a while. This is a temporary carryover: it, its table and its import are deleted once GRAIN_CARRY_COOKIES_UNTIL has passed.

grain-import (crates/grain-import/src/lib.rs):

  • Each _oauth_sessions row becomes a web_session in grain's own grain::session::Session shape: tokens, the PDS, the auth server (falling back to the PDS), the token endpoint (falling back to <issuer>/oauth/token), token_expires_at (seconds; none reads as expired, so it is refreshed first), requested_scope, the client ID <--public-url or GRAIN_PUBLIC_URL>/oauth-client-metadata.json, and as DPoP key the secret scalar of appview-oauth-key, which every hatk PDS session is bound to (dpop_jkt is checked against its RFC 7638 thumbprint). Rows bound to another key, or with no refresh token, are skipped and counted: the summary prints in-hatk vs carried, and why none carried if none did.
  • It derives the cookie AES key: HKDF-SHA256 over JSON.stringify(appview-oauth-key's private JWK, members sorted), empty salt, info hatk-session-cookie (oauth/server.ts:203 passes serverPrivateJwk, the appview-oauth-key row, to initSession). It stores only the 32 bytes, in a new table carried_cookie_key (migration V8, neutral, checked to be 32 bytes). The JWK itself is not imported, so runtime never sees it or the derivation.

Runtime (crates/grain-serve/src/login/carryover.rs, the only runtime file that names the old server):

  • It is a layer on the whole router, so reads and writes on the same request both see the viewer.
  • It acts on a request with __hatk_session and no grain_session, before the end date. It opens the cookie with AES-256-GCM (<b64url iv>.<b64url ct>, 12-byte IV, payload {did, handle, ts}, 30 days from ts as hatk held it). It then mints grain_session for that DID with the cookie key, signed now, so iat >= since (imported sessions have since 0), and keeps it only if held() accepts it: a web_session exists and the account isn't taken down.
  • It does the same for each DID in __hatk_session_accounts (newest first), into grain_accounts.
  • The new cookies are put ahead of the request's own Cookie header and set on the response. Both old cookies are cleared (Max-Age=0; Path=/). Each account carried is logged as carried a web login with its DID only.

Hard stop. GRAIN_CARRY_COOKIES_UNTIL, an RFC 3339 time (validated; the server refuses to start on a bad one). Unset, or no key imported, carries nothing; past it, nothing is carried.

CI. rust.yml's old-name check excludes crates/grain-serve/src/login/carryover.rs by path, beside crates/grain-import.

Why

You asked for web users to stay signed in through cutover. The old cookie format is confined to one runtime file and the throwaway import, and the old JWK and its derivation stay in the import, so deleting the carryover later is three removals: the file, the table, and the import's two functions.

How it was checked

  • The cookie format, against the old server's own code. The two sealed fixtures in carryover.rs were generated by hatk's own encrypt on Node (from the earlier round), under a JWK whose HKDF key grain-import's test derives to the exact bytes carryover.rs opens them with (a0fb2a23…6903, also computed independently with Python's hmac). The test helper seal() round-trips through the same opener.
  • Unit tests (carryover.rs): it opens within 30 days and not after; the accounts are read newest first; tampered, truncated-IV, non-JSON and wrong-key values open as nothing.
  • Unit tests (index/sessions.rs, lib.rs): the 32-byte table check, RFC 3339 parsing, and a bad setting refusing to start.
  • grain-import tests: sessions carried and skipped, with and without a public URL or the key, and with an older table layout; the cookie key stored as derived, and not stored with no key or a broken JWK.
  • tests/login.rs (reference PDS):
    • a_browser_logged_in_before_stays_logged_in: an old session cookie plus accounts cookie becomes grain_session and grain_accounts on one request, the old cookies are cleared, and the next request holds by grain's cookie alone. updateSeen succeeds on the very request that carries. A browser already holding grain_session keeps its own.
    • a_cookie_that_does_not_hold_carries_no_one: wrong key, a tampered IV, a DID with no carried session, a taken-down account, past the end date and a server not set to carry all leave the browser unauthenticated. An accounts cookie naming a DID with no session carries the main account only.

The rest is left to the checks.

Shortcomings

  • The carryover runs a little work on every request carrying the old cookie until it's cleared. After the first answer it is gone from that browser.
  • A browser whose old cookie names an account whose carried session later fails to refresh is logged out on first use, as any session would be.
  • token_expires_at units (seconds) were checked against the old server's code and fixtures, not a production backup; check during the cutover rehearsal.
  • Delete login/carryover.rs, carried_cookie_key (a later migration drops it), the GRAIN_CARRY_COOKIES_UNTIL setting and the rust.yml exclusion once the date passes.

Review

crates/grain-serve/src/login/carryover.rs (carry, especially the cookie-header rewrite and the held() gate), then grain-import's copy_sessions / cookie_key / copy_cookie_key, and V8. Stacked on web/hatk-sessions (pull 3mz7pjygpcuoh), itself on web/login; its own diff is the last commit.

🤖 Generated with Claude Code

Approved

This adds a temporary layer that opens hatk's __hatk_session and __hatk_session_accounts cookies and mints grain_session and grain_accounts for them. It also adds an import step that carries the web sessions and derives the cookie key.

I read carryover.rs in full. I did not read the import, the V8 migration or the integration tests: the full pull diff was too large and includes the stacked pulls. The carry logic holds up:

  • A carried login needs a decryptable cookie, no existing grain_session, a date before the cutoff, and a held() session that isn't taken down.
  • The new cookies go ahead of the request's own, and the old ones are cleared.
  • A tampered, truncated or wrong-key value opens as nothing.

One thing to confirm in the cutover rehearsal, next to the token_expires_at units you already flagged. The unit tests pick ts themselves (TS = 1793500000, in seconds), so the sealed fixtures prove the cipher and key but not hatk's ts unit. If hatk stores ts in milliseconds, now.saturating_sub(ts) is 0 and the 30-day expiry never applies. Carrying would still stop at GRAIN_CARRY_COOKIES_UNTIL, so the exposure is bounded. Check a real cookie's ts against Date.now() in hatk's session.ts.

The checks were still queued or running when I looked, so I haven't seen their results. Before merging, make sure harness and rust pass.

On ts's unit: it is seconds. hatk's packages/hatk/src/oauth/session.ts seals { ...data, ts: Math.floor(Date.now() / 1000) } (line 87) and the accounts cookie the same way (line 110), and refuses Date.now() / 1000 - data.ts > MAX_AGE with MAX_AGE in seconds (lines 8, 103, 127), so now_secs() - ts <= MAX_AGE here matches it. No change pushed for this.

🤖 Generated with Claude Code

Approved

This adds a temporary layer that turns hatk's __hatk_session and __hatk_session_accounts cookies into grain_session and grain_accounts. It also adds the import of web sessions and the derived cookie key. All four checks (test, rust, harness, check) pass on c106f70.

I found nothing that must change. I read carryover.rs again, and the held() gate, the cookie-header rewrite and the end-date stop are as the body describes.

My earlier note about the ts unit is still open. open() in carryover.rs does now.saturating_sub(ts) with ts read as seconds, and the sealed fixtures use TS = 1793500000. Those fixtures prove the cipher and the key, but not the unit hatk writes. If a real hatk cookie holds milliseconds, the 30-day expiry never applies. Carrying still stops at GRAIN_CARRY_COOKIES_UNTIL, so the exposure is bounded. Compare a real cookie's ts with Date.now() during the cutover rehearsal, next to the token_expires_at check you already listed. If it turns out to be milliseconds, divide it by 1000 in open().

Merged as cc27c5c
Part of a stack of 2
  1. Web logins from before cutover carry over, until a set date Landed as cc27c5c
  2. main