Skip to content

Unlock encryption keys once per sign-in - #190

Merged
markwylde merged 8 commits into
mainfrom
spec/session-bound-unlock
Sep 23, 2026
Merged

markwylde merged 8 commits into
mainfrom
spec/session-bound-unlock

Conversation

@markwylde

Copy link
Copy Markdown
Member

Problem

Encryption keys lived in one tab's memory. Sign in on the portal, close the tab, open an app in a new tab, and Authorize failed with "Missing export key" — even though the session was still valid. Signed in and unlocked had very different lifetimes: days versus one tab.

Approach

Modelled on Proton's persisted sessions (auth/v4/sessions/local/key). The browser stores ARK as an AES-GCM envelope in localStorage; the key that opens it is generated by the API and lives in the sign-in's session data. Neither half alone reveals ARK. Logout, expiry, password reset, SCIM deactivation and sign-in revocation all destroy the server half.

Trade-off, stated in specs/SECURITY.md: a copied browser profile or same-origin script can restore ARK while the sign-in is live, where memory-only custody would have required the password again. Memory-only still lost keys to XSS whenever the page was in use, and cost a prompt per tab.

Also in this PR

  • Remembered app approvals (user_client_consents), per-client rememberConsent, and OIDC prompt support. Not remembered when the user has several eligible organizations.
  • Sign-ins: signInId carried across refresh rotation; portal list with per-sign-in and "all others" revoke; relying-party refresh sessions inherit the sign-in so revoking ends them too.
  • Portal: app approvals list with revoke.
  • Fixes: "Choose another method" now stops trusted-device polling; password verification failures no longer report finalization errors as a wrong password; every browser storage access is guarded; authorize actions stack full-width; OTP sign-ins no longer need a second unlock.
  • Specs updated (SECURITY.md, key management, sessions, OIDC, portal) plus tasks/session-bound-unlock.md and tasks/sdk-session-bound-cak.md.

Tests

pnpm tidy, pnpm build, 212 API + 72 user UI + 17 admin UI unit tests, and the 126 existing Playwright tests all pass.

Not verified in a browser yet. A Playwright test for the new-tab flow is still open: the demo harness serves DarkAuth and the demo on localhost, so the demo reuses the refresh cookie and never reaches /authorize. Reproducing it needs distinct sites.

Known gaps (tracked in the task)

  • /token/organization creates a relying-party session with no sign-in link, so revoking a sign-in does not end it.
  • An expired authorization request asks the user to return to the app instead of restarting.
  • Relying-party key persistence across reloads is a separate task, design open.

Keys were held in memory only, so a new tab or reload lost them and the
authorize screen failed with "Missing export key" even while signed in.

The browser now keeps ARK as an AES-GCM envelope in localStorage whose key
lives on the sign-in's server session, modelled on Proton's persisted
sessions. Neither half alone reveals ARK, and logout, expiry, or revoking a
sign-in destroys the server half.

Also adds remembered app approvals with a per-client toggle and OIDC prompt
support, a portal list of sign-ins and app approvals with revoke, and fixes
device-approval polling, password error reporting, and unguarded browser
storage access.
The embedded pglite driver returns bytea columns as Uint8Array, where
toString("base64url") yields a comma-separated byte list. Key envelopes
were therefore served as "123,34,..." and the browser failed to decode
them, so post-login key unlock silently failed.

Found while verifying session unlock end to end against a local instance.
- Access tokens carry sid, so sessions created later from a token (organization
  switching) inherit the sign-in and are revoked with it.
- Expired authorization requests return to the client through
  POST /authorize/restart with error=invalid_request instead of stranding the
  browser on an error page.
- Guard every browser storage access; an unguarded clearLegacyTokens in api.ts
  ran on each 401/403 and could replace a real error with a storage exception,
  which explains a wrong-password report during device-approval polling.
- Add a cross-site Playwright test (DarkAuth on localhost, app on 127.0.0.1)
  covering new-tab authorize with no unlock prompt and silent finalization on
  reload.
- Remembered consent no longer auto-approves when the user has more than one
  active organization, or when the request names an organization other than the
  consented one. A client could otherwise get silent approval for an
  organization the user never approved.
- prompt=login and prompt=select_account now require a fresh authentication;
  previously they only disabled remembered consent while the existing session
  could still approve the request.
- prompt=none returns interaction_required to the client when the browser
  cannot restore the key without asking the user, instead of showing an
  interactive unlock step for a silent request.
…-side

- Remembered consent now requires the sole active organization to be the one
  the consent was granted for. Previously, if that organization was removed and
  another became the only active one, it was silently approved.
- pending_auth records the request's prompt, and finalization rejects
  prompt=login or prompt=select_account unless the session signed in after the
  request was created. The UI check alone could be bypassed by calling
  /authorize/finalize directly with the request id.
The api and branding test scripts used an unquoted src/**/*.test.ts glob.
pnpm runs scripts with sh, where ** behaves like *, so 27 of 64 API test
files never ran in CI, including everything under src/controllers/user.
Quoting the pattern lets Node expand it.

Eight of those files then hung: closing the embedded database while a
fire-and-forget audit write is still in flight deadlocks inside the WASM
runtime, so cleanup never returned. Closing now lets in-flight work settle
first.

Also repairs the stale tests this exposed: missing scimUsers.findMany and
getHeader in test doubles, dark-theme branding values that changed with
semantic tokens, and an authorization-code helper that never set
requireOrganizationSelection.

Also lets prompt=select_account finish as a different account: the pending
request is no longer bound to the account that started it.

341 API tests now run and pass, in 68 seconds.
- ThemeToggle.readStoredTheme called itself instead of reading localStorage,
  so a saved theme was never restored. Introduced by the storage guard pass.
- The authorize route was not behind OtpGate, so a user with enforced OTP who
  signed in from an app redirect reached the consent screen with an unverified
  session and every call 401'd.
- restoreSessionArk deleted the stored envelope when the key or keybag request
  failed, so one network blip forced the password again. Only a real decrypt,
  subject, key or policy failure clears it now.
- Consent was recorded even for clients with rememberConsent disabled, so the
  portal listed an approval that never skips the screen and revoking it signed
  the user out of that app.
- The prompt=login marker lived in component state, so reloading the authorize
  page sent an already re-authenticated user back through the password form.
  It is now kept for the tab.
- unlockOrCreatePasswordArk treated any failed wrapped-DRK request as "no key
  exists" and generated a replacement, so a transient error could overwrite the
  account root and strand existing encrypted data. It now creates a key only on
  a 404, and API errors carry their status.
- Recording consent unioned the previous scopes while replacing the
  organization, so scopes approved in one organization could be reused in
  another. Scopes now reset when the organization changes.
- First-time OTP setup reloaded the page, discarding the password-derived key
  held in memory and forcing another unlock. The OTP journey now navigates
  within the app.
@markwylde
markwylde merged commit 4eec167 into main Sep 23, 2026
24 checks passed
@markwylde
markwylde deleted the spec/session-bound-unlock branch September 23, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant