Skip to content

feat: Sign in on the secure window for a session card code can't reach, and require it to mint key write tickets - #6776

Merged
habdelra merged 25 commits into
mainfrom
cs-13996-the-secure-window-signs-in-on-its-own-a-credential-card-code
Oct 19, 2026
Merged

habdelra merged 25 commits into
mainfrom
cs-13996-the-secure-window-signs-in-on-its-own-a-credential-card-code

Conversation

@habdelra

@habdelra habdelra commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Important

Deploy in this order. Google sign-in on the secure window needs three things, and a step taken early either does nothing or shows users a Google button that fails.

  1. Register the redirect URIs on Synapse's Google OAuth client in Google Cloud, for each environment. This can happen any time before step 3.
    • production: https://secure.boxel.ai/google-callback
    • staging: https://secure.stack.cards/google-callback
  2. Merge and deploy feat: Sign in on the secure window for a session card code can't reach, and require it to mint key write tickets #6776. Password sign-in works from here; the secure window doesn't offer Google yet.
  3. Apply cardstack/infra#758, which gives the realm server the Google client. The secure window then offers Google. Applied before step 1, Google rejects every sign-in with redirect_uri_mismatch. Applied before step 2, the realm server ignores the setting.

What this does

Card code runs in the app page, where it can read the user's realm-server session and Matrix access token and carry both out of the browser. So the app's session can't authorize a change to a user's key. This adds a session card code can't reach: a secure session, signed in on the secure origin. Minting a key write ticket now requires one.

  • The secure window signs in on its own, as a top-level page on the secure origin, with a password or with Google. It keeps its session in that origin's storage and never posts it to the app.
  • The CLI gets a secure session through the secure window. boxel keys sign-in opens the window in the browser, and the window hands the session to the CLI's loopback listener. The CLI's ordinary sign-in doesn't carry the claim.

Why a Matrix sign-in isn't enough

Synapse runs login_via_existing_session without interactive auth, so any Matrix access token can mint a login token for a fresh device with any device id. That includes the app's token, which card code can read. A Matrix sign-in, however it was made, therefore proves nothing card code couldn't produce. What card code can't produce is the user's own credential, typed or confirmed on the secure origin.

Password (POST /_secure-session)

One request: attributes.{username, password, client}.

  1. Resolve the account from a Matrix id, an email address (admin threepid lookup) or a bare username.
  2. Start a re-auth check. Mint a short-lived token for the user through Synapse's admin "log in as user" API, and send account/3pid/add with a made-up validation session. That request always requires user-interactive auth (UIA).
  3. Complete it with the password, under a fresh admin-minted token. Synapse runs the request only once the password is right. It then fails with M_THREEPID_AUTH_FAILED, having changed nothing, and that failure is the proof. Both tokens are logged out.

Why account/3pid/add: it never skips UIA because of a recent sign-in, and it fails harmlessly once auth passes. Device deletion, by contrast, skips UIA for a fresh token whenever ui_auth.session_timeout is set.

What failures reveal: nothing. A missing account, an account that has never signed in to Boxel, an account with no password and a wrong password all get the same 401 invalid-credentials. A Synapse rate limit is reported as 429 rate-limited.

Rate limit: the route is limited per account (5 attempts per 5 minutes) and per caller address (30), checked before any admin call.

Google (GET / POST /_secure-session/google)

The realm server is its own OpenID Connect client, using the Matrix server's Google client, so users see no new consent (lib/google-sign-in.ts).

  1. Start. The window asks GET /_secure-session/google for the authorization endpoint, the client id and the redirect URI <shell>/google-callback. It keeps a state, a nonce and a PKCE verifier in the secure origin's session storage, and redirects to Google in the same window.
  2. Return. Google redirects back to <shell>/google-callback, which serves the shell. The window checks the state, removes the code from the address bar, and sends POST /_secure-session/google (attributes.{code, codeVerifier, nonce, client}).
  3. Verify. The server redeems the code at the token endpoint with the client secret, against its own redirect URI, never the caller's. It checks the ID token's issuer, audience, expiry and nonce. The token comes straight from Google over TLS in answer to the server's own authenticated request, so its claims are checked rather than its signature (OpenID Connect Core §3.1.3.7).
  4. Find the account. It's the account Synapse linked to the Google sub, through GET /_synapse/admin/v1/auth_providers/oidc-google/users/<sub>. Boxel's OIDC mapping provider stores sub verbatim as the external id, so every existing Google user works with no migration. The server never links by email itself, and it refuses a deactivated account.

Why card code can't complete it: Google sends the code only to the registered redirect, which is the secure origin, and only the server can redeem it. A code card code causes Google to issue lands on the secure origin, where the window drops it because it didn't start that sign-in.

Both POSTs, and the Origin rule

Both POSTs answer only a request whose Origin is the secure origin's.

  • In the browser, card code can't send that Origin.
  • Outside the browser, an Origin header can be forged, but the request still needs the user's password or a code Google sent to the secure origin.

The claim

  • What it gates: POST /_key-write-tickets requires isSecureSession. That means the token carries the claim, and its secure_sessions row belongs to the token's user and hasn't expired. The Origin rule from before still applies: the secure origin or none.
  • Who can mint it: only createSecureSessionJWT, called only from lib/secure-session.ts. createJWT (realm-server sessions) and the realm session minter in jwt.ts refuse claims that carry it.
  • Expiry and sign-out: a secure session lasts seven days, as long as the CLI's extended session. DELETE /_secure-session signs it out by expiring its row. Rows stay two days past expiry, so the proof that signed in can't sign in again. Revoking all of a user's sessions ends it too.

secure_sessions (1792107467874_add-secure-sessions)

Column Holds
id UUID the claim names
user_id Matrix user id
client window or cli
sign_in_proof uia:<UIA session> for a password, google:<sha256 of the code> for Google; unique
expires_at When the session ends
  • The table is withheld from readonly_role and from every role that reads through it (granted it with INHERIT or SET). The table's owner keeps its own SELECT: on Postgres 16, the role that creates readonly_role is an ADMIN-only member of it, and is often the owner.
  • After revoking, the migration checks every column against readonly_role and every role that reads through it, directly or through other roles. If any can still read one, for example through a grant another role made, the migration fails, naming the role and column.
  • The table is excluded from the SQLite schema dump.
  • The schema snapshot is re-stamped to the migration, with identical content.

The secure window (secure-origin/)

  • shell.js serves the modes:
    • sign-in: the window's own session, kept in the secure origin's localStorage.
    • cli-sign-in: takes port, state and an optional user.
    • the Google callback path, which finishes either one.
  • The first screen mirrors the app's login: Continue with Google, then email or username and password. Signed in, the window says so and that it can be closed; boxelSignOut() in its console signs it out.
  • The CLI hand-off: the session is posted as a form to http://127.0.0.1:<port>/callback and never stored. The CLI's port and state survive the Google round trip in session storage.
  • session.js holds the window's session, and its mintKeyWriteTicket mints tickets with that session, for the key-entry mode to use.
  • CSP: the shell page's policy adds connect-src to the realm server and form-action http://127.0.0.1:*. Nothing else leaves the origin.

The CLI

  • boxel keys sign-in, boxel keys sign-out and boxel keys status.
  • Storage: the session is kept in the active profile as secureSession. It carries over a re-authentication against the same servers, and is dropped when they change.
  • Sign-out: the local session is forgotten only once the server has ended it.
  • Wrong account: a sign-in as a different account than the profile's is refused and signed out.
  • lib/secure-session.ts has mintKeyWriteTicket and addUserKey for the key commands to build on.

Logs and Sentry

  • /_secure-session and everything beneath it join the key routes in keepsMethodAndPathOnly, so request logs and Sentry keep only method and path.
  • Handlers log an error's name and message only, never a body.

Deploying

  • Synapse admin credentials: the realm server needs MATRIX_ADMIN_USERNAME / MATRIX_ADMIN_PASSWORD, which staging and production already set. Without them, sign-in answers 503.
  • Google client (environment): the realm server needs GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET, set from the SSM parameters Synapse already uses (synapse_google_oauth_client_id / _secret), by cardstack/infra#758. Until they're set, the window offers only a password. GOOGLE_OAUTH_ISSUER points the realm server at a stand-in provider in tests.
  • Google client (Google Cloud): each environment's client needs its secure-origin callback as an authorized redirect URI before that infra change is applied:
    • production: https://secure.boxel.ai/google-callback
    • staging: https://secure.stack.cards/google-callback
    • local dev, with your own client: https://localhost:4208/secure/google-callback
  • ADR 005 (docs/adr/005-authorize-key-changes-only-with-a-session-card-code-cant-reach.md, proposed) records when an operation needs a secure session, the rules that keep one out of card code's reach, why, and the options rejected.
  • Operator docs: .claude/skills/secure-session/SKILL.md covers the how-to: the claim, the table, both sign-ins, every refusal and the configuration. It cites ADR 005 for the why. The user-keys skill names the new minting rule.

For users

Managing your keys asks you to sign in. When you go to manage your keys, Boxel asks you to sign in, even if you're already signed in.

  • Google: click Continue with Google, choose your account, and you're back, signed in.
  • Password: enter your email address or username and your Boxel password.

You stay signed in to manage your keys for a week.

boxel keys signs in through the browser. The CLI's ordinary sign-in (boxel profile add) can't change your keys.

  • boxel keys sign-in opens Boxel's sign-in in your browser with your username filled in. Sign in there, and the CLI on the same computer is signed in to manage your keys.
  • boxel keys status says whether the CLI is signed in to change keys.
  • boxel keys sign-out ends that session.

Sign in as the same account as the CLI's active profile; signing in as a different account is refused.

Screenshots

These show the real sign-in pages, rendered in Chrome, with the realm server's answers stubbed and a made-up example-user. matrix/tests/secure-window.spec.ts runs the same screens end to end against real Synapse. The Google step is simulated: screenshot 4 is a labelled stand-in for Google's real account chooser.

Password

1. Sign in 2. A wrong password 3. Signed in
Sign in to Boxel: Continue with Google, then email or username and password The same form showing "That account and password don't match." You're signed in to Boxel. You can close this window.

Google

4. Google's account chooser (simulated) 5. Back, signed in
A simulated Google "Choose an account" page, labelled as a simulation You're signed in to Boxel. You can close this window.

boxel keys sign-in opens the same sign-in, with the CLI profile's username filled in:

6. The CLI's sign-in
Sign in to Boxel, with the username already filled in

Not in this PR

  • Grants: creating a grant must accept only a secure session (CS-13997). New grants from the terminal are confirmed in the secure window through a single-use link (CS-13878). Grants don't exist yet; both tickets carry the requirement.
  • Key-entry mode and key commands: the secure window's key-entry mode (CS-13866) and the remaining boxel keys commands (CS-13875) build on session.js and the CLI's lib/secure-session.ts.

Testing

realm-server/tests/secure-session-test.ts, against real Synapse, with a stand-in OpenID provider playing Google:

  • Password sign-in: it gives a session that carries the claim, mints tickets with the secure origin's Origin or none, and adds a key. Checking the password changes nothing on the account. The account can be named by email address.
  • Password refusals and limits: a wrong password, no such account, no such email and an account that has never signed in to Boxel all get the identical answer. Attempts are limited per account.
  • Origin: both sign-ins are refused from the app's Origin, an opaque one or none.
  • Google: the window gets the authorization endpoint, client id and the secure origin's callback. A linked Google account gets a session, and the code is redeemed for the secure origin's callback.
  • Google refusals: an unlinked account, another sign-in's nonce, the wrong PKCE verifier, another client's, another issuer's or an expired token, and a deactivated account are each refused.
  • Single use: a Google code signs in once, even with a provider that would redeem it twice, and still after its session has signed out.
  • The app's session: can't mint a ticket from the secure origin's Origin, any other or none.
  • Rows: a claim naming no row, another user's row or an expired row is refused. Signing out ends the session.
  • No other path mints the claim: both session minters refuse it, and createSecureSessionJWT has no other caller.
  • Logs: no password, code or verifier reaches the request log, and the routes keep only method and path.
  • The read-only role: the migration leaves readonly_role, and a member also granted SELECT directly, unable to read secure_sessions, while the table's owner still can. A grant the migration can't revoke makes it fail, naming the role.

realm-server/tests/user-keys-test.ts mints with secure sessions. secure-origin-test.ts covers the shell page's connect-src and form-action, the Google callback path serving the shell, the new scripts' headers, and the page naming the realm server without loading anything from it.

boxel-cli:

  • tests/integration/keys-sign-in.test.ts: adding a key with only the CLI's ordinary session is refused. boxel keys sign-in, with the test acting as the browser and the secure window, then adds a key. After boxel keys sign-out the session mints nothing.
  • tests/lib/keys-sign-out.test.ts: a failed sign-out keeps the session.
  • tests/commands/profile.test.ts: re-authentication keeps the session for the same servers and drops it for others.

matrix/tests/secure-window.spec.ts, in a real browser with real Synapse and the mock Google provider:

  • Password sign-in: the window opened from the app signs in with a password. The app page finds the session nowhere: not in its storage, not through the window it opened, and not in any message. The window mints a ticket with its own session, and after sign-out that session mints nothing.
  • Google: a Google user signs in through a same-window round trip, with no code left in the address. An unlinked Google account is refused.
  • CLI: the window hands a session to a listener on this machine and keeps none itself, and it refuses a link that names no listener.

🤖 Generated with Claude Code

habdelra and others added 2 commits October 16, 2026 00:11
…ckets

The secure window signs the user in by having the realm server drive a
Matrix user-interactive auth check (an account/3pid/add request with a
made-up validation session) that the user completes with their password
or single sign-on. A completed check mints a realm-server session carrying
a `secureSession` claim backed by a `secure_sessions` row; minting a key
write ticket now requires it. The CLI gets one through the secure window
via its loopback listener (`boxel keys sign-in`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… SSO confirmation

Playwright covers the password and Google sign-ins, the CLI hand-off and
what the app page can't see, attaching a screenshot of each step. The
isolated realm server gets the suite's Synapse admin. Synapse's SSO
confirm/success/bad-user pages take Boxel's layout and keep the action
description visible. Operator skill for secure sessions; the user-keys
skill names the new minting rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T12:34:46.740204Z 1135605 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   1h 46m 53s ⏱️ + 8m 7s
5 708 tests +1  5 697 ✅ +1  11 💤 ±0  0 ❌ ±0 
5 723 runs  +1  5 712 ✅ +1  11 💤 ±0  0 ❌ ±0 

Results for commit 3c24dcc. ± Comparison against earlier commit e20c8c0.

Realm Server Test Results

    1 files  ±0    344 suites  ±0   2h 5m 54s ⏱️ - 5m 31s
5 810 tests ±0  5 810 ✅ ±0  0 💤 ±0  0 ❌ ±0 
5 873 runs  ±0  5 873 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 3c24dcc. ± Comparison against earlier commit e20c8c0.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1135605912

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +157 to +160
let started = await startSignIn(deps(args), {
username,
client: client as SecureSessionClient,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rate-limit unauthenticated secure sign-in starts

A non-browser client can forge the required Origin and repeatedly call this unauthenticated route with any known username. Every accepted call performs an admin impersonation, creates a Matrix access token and UIA session, and logs the token out without requiring an authentication stage, allowing unbounded privileged Synapse work and session allocation; apply a server-side per-address/per-account limit before calling startSignIn.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 85a6ff4. Password sign-in, the only sign-in a caller without a credential can start, is limited per account and per caller address before any admin call. Google sign-in does admin work only after a code has been redeemed, which takes a real Google sign-in.

Comment on lines +66 to +69
let session = pm.getSecureSession();
pm.clearSecureSession();
if (session) {
await secureSignOut(profile.realmServerUrl, session.token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the session until remote sign-out succeeds

When the DELETE request fails because of a transient network or server error, this clears the only persisted token before reporting the failure. The user cannot retry the revocation, while the corresponding server row—and any copied bearer token—remains usable for up to eight hours; clear the local session only after secureSignOut succeeds or treats a 401 as already signed out.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 85a6ff4. boxel keys sign-out forgets the session only once the server has ended it, or answers 401 because it already had. A failed sign-out keeps the session so it can be tried again (tests/lib/keys-sign-out.test.ts).

Comment on lines +173 to +176
`_synapse/admin/v1/threepid/email/users/${encodeURIComponent(name.toLowerCase())}`,
admin.matrixURL,
),
{ headers: { Authorization: `Bearer ${adminToken}` } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid exposing email-to-Matrix account mappings

Because the route is unauthenticated and its Origin check is forgeable outside a browser, any caller can submit an email here and reach this admin-only threepid lookup without knowing the password. The distinct unknown/no-Boxel/success responses—and the resolved user returned on success—reveal whether an email has a Boxel account and its Matrix ID before UIA is completed; defer that disclosure until authentication succeeds or otherwise make the start response non-enumerable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in c288e7e. The start step is gone: password sign-in is one request that resolves the email address and checks the password together. Every failure is the same 401, and the Matrix id is returned only with a session.

Comment on lines +81 to +84
// A session signed in on the secure origin, through the browser (`boxel
// keys sign-in`). Only it can authorize a change to the user's keys; the
// `realmServerToken` above, from the CLI's ordinary sign-in, can't.
secureSession?: SecureSessionRecord;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve secure sessions when re-authing an unchanged profile

addProfileWithAuth reconstructs the Profile and preserves realmTokens and realmServerToken when the URLs are unchanged, but it never copies this new field. Consequently, routine boxel profile add re-authentication silently loses the key-management sign-in while leaving its server-side session active; carry secureSession forward when the endpoints are unchanged and invalidate it deliberately when they change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 85a6ff4. addProfileWithAuth carries secureSession over when the servers are unchanged and drops it when they change, and updateUrls clears it with the other server-bound tokens. Covered in tests/commands/profile.test.ts.

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This review looked for ways card code, or any outside party holding the app's tokens and free to forge Origin, could obtain or reuse a token carrying secureSession. It traced the sign-in, completion, ticket gate, CSP, CLI loopback hand-off, migration and the claim-minting guards, and checked the UIA behaviour against the Synapse version the suite pins (v1.126). It did not run the realm-server, CLI integration or matrix suites, and did not review the CSS or the visual templates.

Bottom line: one blocking decision. For accounts that sign in with Google, the single-sign-on confirmation isn't bound to the secure window, so whoever starts a sign-in can get a secure session out of one click-through by the user. The rest of the gate holds: the claim minters, the row check, Origin, CSP and COOP all work as stated.

  1. Blocking (decision): SSO sign-ins can be started by anyone and completed by the user in a popup someone else opened. Either accept this and correct the "card code can't obtain" claims, or bind the stage to the secure window. See the thread on ssoURL in lib/secure-session.ts.
  2. Non-blocking: signing out (or an operator deleting rows) frees uia_session, so a signIn can be replayed within its 10 minutes. See the thread on endSecureSession.
  3. Non-blocking: the unauthenticated startSignIn lets anyone check whether an email address has an account, has no throttle, and turns Synapse's failed-attempt rate limit into a lockout that reports not-confirmed. See the thread on startSignIn.
  4. Non-blocking: the header comment in shell.js contradicts how cli-sign-in uses the query string. See the thread there.

Out of scope, for whoever touches it next: /_secure-session/sign-in works only while Synapse's enable_3pid_changes stays on (the default). If it is turned off, sign-in answers 503 with no hint why, so it belongs in the skill's Dependencies.

Comment on lines +334 to +339
let ssoURL = stages.includes('sso')
? new URL(
`_matrix/client/v3/auth/m.login.sso/fallback/web?session=${encodeURIComponent(uiaSession)}`,
deps.matrixClient.matrixURL,
).href
: undefined;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] For an SSO account, whoever starts a sign-in can get a secure session out of one click-through by the user, and nothing ties that click-through to the secure window. Card code, or anyone on the internet, can be that starter. Deciding what to do here should block merge, and at minimum the "card code can't obtain" claims need correcting.

The path:

  1. From outside the browser, send POST /_secure-session/sign-in with a forged Origin and the victim's username. It needs no session, and the response includes signIn and ssoURL.
  2. Get the victim to open ssoURL. Card code can window.open it after any click in a card, or it can arrive as a link. It is Synapse's real fallback page on the real Matrix domain, and it shows the same "Confirm it's you … add a third-party identifier" text as the real flow.
  3. Once they click Continue with Google (often one click with an existing Google session), poll POST /_secure-session with signIn and no password. The secure session comes back, and tickets to add, replace or delete the user's keys follow.

Checked against Synapse v1.126 check_ui_auth / complete_sso_ui_auth: completing the SSO stage marks the UIA session complete for whoever presents its id, and the only check is that the IdP's user matches the session's user. The template's "If you didn't just start signing in to Boxel's secure window, close this page" doesn't hold up, because the attacker can open the real secure window first so that it is true.

The password stage doesn't have this gap in the same form: there the user has to type the password, and card code would need a look-alike field on the app origin.

Options:

  • Accept it as residual risk. Then reword the load-bearing claims: the mayMintKeyWriteTicket comment in handle-user-keys.ts ("which card code can't obtain"), the header of session.js, and the opening of the secure-session skill. The new skill should also tell operators how to find and end such a session. Telling the user through their session room whenever a secure session is created would at least make it visible.
  • Bind the SSO stage to the browser that started it. The UIA fallback has no channel for this. It would need a different mechanism, such as an SSO redirect that only the secure origin receives, or a second factor shown only in the secure window.

Class: regression (new code that doesn't meet the guarantee it states).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in c288e7e. Google sign-in no longer goes through Synapse's re-auth, so there's no confirmation link for a caller to hand out. The realm server is its own OpenID Connect client, using Synapse's Google client. The secure window keeps the state, nonce and PKCE verifier in the secure origin's session storage and redirects to Google. Google returns the code only to the secure origin's registered callback (<shell>/google-callback). The server redeems the code with the client secret, against its own redirect URI, checks the ID token's issuer, audience, expiry and nonce, and finds the account Synapse linked to that sub through the admin API. A code card code causes Google to issue lands on the secure origin, where the window drops it because it didn't start that sign-in. The Synapse SSO templates and the popup are gone.

Comment on lines +520 to +524
}
let rows = await dbAdapter.execute(
`DELETE FROM secure_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
{ bind: [id, token.user] },
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Signing out deletes the row, and with it the uia_session uniqueness that makes "one check signs in once" true. Synapse doesn't consume a completed UIA session: check_ui_auth keeps it complete until SESSION_EXPIRE_MS, which is 48h. So within the sign-in token's 10 minutes, replaying the same signIn to POST /_secure-session after DELETE /_secure-session mints a fresh session. The same applies after an operator deletes rows, which the secure-session skill gives as the way to sign a user out.

Fix: end a session without freeing its uia_session. One way is to set expires_at = NOW() here rather than deleting, and have the sweep in createSecureSession delete only rows that are expired and older than SIGN_IN_TTL_SECONDS, for example expires_at < NOW() AND created_at < NOW() - interval '10 minutes'. Then update the skill's "delete their rows" advice to match. A test that signs in, signs out, and replays the same signIn expecting 409 used would pin it. The "signs in once" test only replays while the row exists.

Class: regression. Non-blocking: the legitimate window keeps signIn only in memory, so exposure is small, but the invariant the migration comment states doesn't hold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in c288e7e. Signing out sets the row's expires_at to now instead of deleting it, and rows are swept only two days after they expire, so sign_in_proof (formerly uia_session) still blocks a second use after sign-out. Password sign-in is now a single request whose UIA session is never handed out, so there's no signIn left to replay. The Google proof is the code's SHA-256. a Google sign-in signs in once, even after its session ends uses a stand-in provider that would redeem the same code twice, and checks both the second use and a use after sign-out.

Comment on lines +284 to +289
export async function startSignIn(
deps: SecureSessionDeps,
{ username, client }: { username: string; client: SecureSessionClient },
): Promise<StartedSignIn> {
let user = await resolveMatrixUser(deps, username);
await sessionRoomFor(deps, user);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] startSignIn can be called with no authentication; the Origin check is the only gate, and it can be forged. It has no throttle and answers differently depending on the account:

  • 401 unknown-user for an email address with no account, or for a username that doesn't exist;
  • 403 no-boxel-account for a username that exists but has never signed in to Boxel;
  • 201 with stages otherwise, which also reveals whether the account uses a password or Google.

That makes it an email-to-account oracle. Each call also costs an admin threepid lookup, an admin login-as-user, a 3pid/add and a logout against Synapse.

A related problem is on the completion side. Synapse's _failed_uia_attempts_ratelimiter is keyed on the user, so anyone can spend a victim's failed-attempt budget through this route. When the limit hits, the 429 lands in completeSignIn's catch-all as 401 not-confirmed ("start again"), so the real user sees a misleading error rather than "try again shortly".

Asks:

  • Rate-limit both routes, per IP and per target user.
  • Collapse unknown-user and no-boxel-account into one answer.
  • Map Synapse's M_LIMIT_EXCEEDED to its own code.

Class: regression (new unauthenticated surface). Non-blocking, though worth landing with the feature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Addressed in c288e7e and 85a6ff4. Password sign-in is one request, and every failure answers the same 401 invalid-credentials: no account, never signed in to Boxel, no password, or a wrong password. The response never names the account or its sign-in methods (a failed password sign-in says nothing about the account). A Synapse 429 is reported as 429 rate-limited. The route is now limited per account (5 per 5 minutes) and per caller address (30), checked before any admin call (password sign-ins are limited per account). Synapse's per-account limit on failed attempts still lets anyone slow a victim's password checks, but that limit belongs to Synapse and applies to /login as well; it isn't new here.

Comment on lines +2 to +4
// The only script the secure origin's shell serves. It loads no card, realm or
// host app code, and nothing the page shows may come from the page that opened
// it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] "Nothing the page shows may come from the page that opened it" no longer holds. cli-sign-in fills in the username from ?user= and presents "Sign in the Boxel CLI on this computer" for any port/state in the URL. Card code can open that URL itself. The session only ever goes to loopback, so this doesn't leak anything, but the comment is the invariant the next mode will be built on.

Either narrow the comment (for example, "a mode takes nothing from the opener except the query parameters it validates, and never shows them as fact"), or drop the user prefill and show the CLI framing only as the user's own choice.

Class: regression (comment contradicts code). Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in c288e7e. The comment now says what holds: a mode takes only plain values from its own URL, such as a username to fill in, and never hands anything to the page that opened it.

habdelra and others added 5 commits October 16, 2026 08:51
…word in one request

Google sign-in on the secure window now goes straight to Google and back
to the secure origin: the realm server redeems the code with the client
secret, checks the ID token against the window's nonce and PKCE verifier,
and finds the account Synapse linked to the Google subject. This replaces
the Synapse SSO popup, which handed its confirmation link to whoever
started a sign-in, and drops the SSO templates it needed.

A password sign-in is one request whose refusals are all the same answer,
so it says nothing about which accounts exist or how they sign in, and a
Synapse rate limit reads as one. Signing out expires a secure session's row
instead of deleting it, and rows stay well past expiry, so the proof that
signed in can't sign in again. The shell's header comment says what a mode
takes from its URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver ends it

A password sign-in on the secure origin is limited per account and per
caller address, since each attempt on a real account has Synapse mint and
log out two tokens. `boxel keys sign-out` forgets its session only once
the server has ended it (or already had), and re-authenticating a profile
against the same servers keeps its key-management sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrors as sentences

Continue with Google spans the form like Sign in, with "or" between two
rules, and an error from the realm server reads as a sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@habdelra
habdelra requested a review from a team October 16, 2026 13:43
habdelra and others added 14 commits October 16, 2026 10:03
The page, its errors and the CLI's prompts no longer name a "secure
window" or say what cards can't reach; the sign-in screen reads "Sign in
to Boxel … to manage your keys", and the signed-in screen drops its note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en to just that

A secure session lasts seven days, as long as the CLI's extended session.
The signed-in screen says only that you're signed in, with Sign out; it no
longer shows the account's Matrix id or when the session ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screen says only that you're signed in and can close the window.
`boxelSignOut()` in the page's console ends the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e with Google

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oogle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eader can read it

On Postgres 16 the role that creates readonly_role is an ADMIN-only member
of it, and is often the table's owner, so revoking from every member took
away the owner's own SELECT. The migration now revokes only from members
that read through the role (INHERIT or SET), never the owner, and then
checks every column against readonly_role and every role that reads
through it, failing with the role and column if any can still read one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we land the main ADR PR first, I'd like to see an ADR about why and when we have decided to use the secure window, and this skill can reference it and be trimmed to the how-to part

@habdelra

Copy link
Copy Markdown
Contributor Author

@lukemelia Agreed. I've drafted it as ADR 003, "Authorize changes to a user's keys only with a session that card code can't reach". It follows ADR 001 and covers:

  • When an operation belongs in the secure window.
  • What a secure session is, and the rule that only createSecureSessionJWT mints the claim.
  • What may sign one in.
  • Keeping the session out of the app page.
  • The CLI hand-off.
  • The rejected options: an Origin gate on the app's session, a Matrix-derived session, device-deletion UIA, Synapse's own Google SSO, and an in-app confirmation.

When #6755 lands I'll rebase this PR onto it. I'll add the ADR, numbered after #6758's 002, and an index row. I'll also trim .claude/skills/secure-session to the how-to and have it cite the ADR by section. If this PR merges first, that goes in a follow-up PR instead.

🤖 Generated with Claude Code

habdelra and others added 2 commits October 19, 2026 06:48
…indow-signs-in-on-its-own-a-credential-card-code
…an't reach

The secure-session skill keeps the how-to and cites the ADR for why, and the
user-keys skill points at it for who may mint a key write ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@habdelra

Copy link
Copy Markdown
Contributor Author

@lukemelia #6755 has landed, so I merged main in and added the ADR in e20c8c0.

node scripts/adr-for.mts finds the ADR from the realm-server, secure-origin and CLI files it governs.

🤖 Generated with Claude Code

@habdelra

Copy link
Copy Markdown
Contributor Author

The red Matrix Client Tests (3, 3) check isn't caused by this PR. Its only two failures are correctness-checks.spec.ts:165 and :575, and they have failed on every branch since about 08:24 UTC today. The likely cause is the unpinned boxel-skills clone picking up boxel-skills#239, which makes the default skill declare run-realm-code. The secure-window spec and the rest of the shard passed (44 passed).

🤖 Generated with Claude Code

…indow-signs-in-on-its-own-a-credential-card-code
@habdelra
habdelra merged commit b6df568 into main Oct 19, 2026
84 checks passed
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.

2 participants