Skip to content

Stop using a certificate the signed-in account does not own (GRYT-905) - #385

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-905-certificate-account
Sep 4, 2026
Merged

Stop using a certificate the signed-in account does not own (GRYT-905)#385
sivert-io merged 1 commit into
mainfrom
claude/GRYT-905-certificate-account

Conversation

@sivert-io

Copy link
Copy Markdown
Member

Signed in as test@sivert.io, the desktop client joined community.gryt.chat as Sivert — the owner, who is root@sivert.io. The member list was right. The identity was not.

⚠️ Review-required path: packages/client/src/packages/common/src/auth/.

What was wrong

getValidCertificate checked a cached certificate two ways — not close to expiry, and the JWK inside it still names the keypair on this device. It never checked who the certificate was for, even though parseJwtSub sits in the same file.

answer-challenge.ts then signs the assertion with the sub it reads back out of that certificate:

const certificate = await getValidCertificate();
const sub = getCertificateSub() || "";
const assertion = await signAssertion(sub, challenge.serverHost, challenge.nonce, { kind: "account" });

So the server is handed a real certificate, signed by the CA, for a key the client genuinely holds, naming somebody who is not at the keyboard. Every check the server can make passes. There is no server-side fix for this one.

The two halves drift because they live apart and are cleared by different code — keypair in IndexedDB, certificate in localStorage — and signOut is the only path that takes both. A certificate outlives a session by a wide margin. The file already documented the other direction of this same drift; the guard written for it only compared the key.

The shape

The rule moves to certificate-verdict.ts, which imports nothing. Needing Keycloak, IndexedDB, fetch and a keychain to reach this decision is how it went unchecked long enough to ship.

Four answers rather than two, because the repairs differ:

use ours, current, matches the key
stale ours and old — fetch, and leave the old one in place until the new one lands
wrong-key ours but names a key we no longer hold — drop it
wrong-account somebody else's — drop it and the keypair under it

What to look at

  • Dropping the keypair on wrong-account. This is the part that changes behaviour rather than shape, and the part I am least sure about. The reasoning: a certificate binds one sub to one key, so minting a new one over the previous account's key hands two accounts the same key, and a server that pinned it sees one key arrive under a second name. I believe it is safe to drop — an account key is crypto.subtle.generateKey, random rather than derived; the CA certifies a fresh one on the next sign-in; DM keys come off the seed, which this does not touch. If you would rather keep the key and only re-certify it, that is a one-line change and the check has a case for it.
  • stale deliberately not clearing storage. That preserves today's behaviour: a renewal that fails offline should not also cost us the sub that getCertificateSub reads back. It does mean an expired certificate for the current account stays on disk, which it already did.
  • Null is not a mismatch. Signed out, session lapsed and Keycloak unreachable all answer null. Treating that as "somebody else" would discard the real user's identity on the strength of a failed token refresh — a worse bug than the one being fixed. Asserted in the check.

Verification

yarn lint (which is tsc -b + eslint) passes. scripts/check-identity-certificate.mjs is new, wired into package.json and CI, and mutation-tested — five mutations, all caught:

mutation
no account check at all (the shipped bug) caught
account checked after the key and expiry caught
null signed-in sub treated as somebody else caught
wrong-account no longer clears the keypair caught
consumer stops calling certificateVerdict caught

Not verified end to end. Reproducing the original needs two real Keycloak accounts on one device, which I cannot drive. The diagnosis is read off the source and the reported symptom, and the fix is checked at the unit and source level.

🤖 Generated with Claude Code

Signed in as test@sivert.io, the desktop client joined community.gryt.chat as
Sivert — the owner, who is root@sivert.io.

`getValidCertificate` checked a cached certificate two ways: not close to
expiry, and the JWK inside it still names the keypair on this device. It never
checked who the certificate was for. `answer-challenge.ts` then signs the
assertion with the `sub` it reads back out of that certificate:

    const certificate = await getValidCertificate();
    const sub = getCertificateSub() || "";
    const assertion = await signAssertion(sub, host, nonce, { kind: "account" });

So the server was handed a real certificate, signed by the CA, for a key the
client genuinely holds, naming a person who was not at the keyboard. Every
check it can make passes. There is no server-side fix for this.

The two halves drift because they live apart and are cleared by different
code — the keypair in IndexedDB, the certificate in localStorage — and
`signOut` is the only path that takes both. A certificate outlives a session by
a wide margin, so anything that ends one without going through there leaves it
behind. The file already documented the other direction of the same drift; the
guard written for it only compared the key.

The rule moves to `certificate-verdict.ts`, which imports nothing, so it can be
run without Keycloak, IndexedDB, `fetch` or a keychain — needing all four is
how it went unchecked. Four answers rather than two, because the repairs
differ:

  use            ours, current, matches the key
  stale          ours and old — fetch, and leave it in place until the new one
                 lands, so a failed renewal offline does not also cost us the
                 `sub`
  wrong-key      ours but names a key we no longer hold — drop it
  wrong-account  somebody else's — drop it *and* the keypair under it

That last one takes the key because a certificate binds one `sub` to one key.
Minting a new one over the previous account's key would hand two accounts the
same key, and a server that pinned it would see one key arrive under a second
name. Dropping it is safe: an account key is random rather than derived, the CA
certifies a fresh one on the next sign-in, and DM keys come off the seed, which
this does not touch.

A null answer for the signed-in account is not a mismatch. Signed out, session
lapsed and Keycloak unreachable all answer null, and discarding somebody's
identity on the strength of a failed token refresh is the worse bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io merged commit f6d060f into main Sep 4, 2026
3 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.

1 participant