Skip to content

Qualify gryt_user_id by its certificate issuer (GRYT-267) - #47

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-267-qualify-user-id-by-issuer
Aug 14, 2026
Merged

Qualify gryt_user_id by its certificate issuer (GRYT-267)#47
sivert-io merged 1 commit into
mainfrom
claude/GRYT-267-qualify-user-id-by-issuer

Conversation

@sivert-io

Copy link
Copy Markdown
Member

Closes the hole you worked out in the Carlo thread: if two CAs are ever trusted, the second one can be anybody the first one vouched for.

verifyCertificate already returned the issuer it verified against. join.ts threw it away and stored the bare Keycloak sub. Since gryt_user_id is what roles, ownership, bans and message attribution all key on, a second trusted CA minting a certificate for a sub that already exists here would inherit all of it. The sub isn't a secret — it's a claim in the certificate handed to every server on every join — so any operator who has seen a user already has what's needed.

Latent today: the trusted list has one entry by default. This is the prerequisite for it ever having two.

What to look at

The primary-issuer trick, which is the debatable part. The first configured issuer owns the unqualified namespace; every other issuer gets its name written into the id. That means no migration — existing rows keep the ids they have.

The alternative is qualifying everything and rewriting gryt_user_id across users, bans, server_config.owner_gryt_user_id and refresh_tokens on live servers with no off-box backups. I didn't think that was worth doing for a hole that can't be reached yet, but it is the cleaner end state and I'd rather you agreed than assumed.

What it trades for: an ordering hazard. Moving the first entry of GRYT_TRUSTED_CERT_ISSUERS changes who owns the unqualified namespace and would silently re-point every account identity on the server. There's a loud comment on getPrimaryCertificateIssuer, but nothing enforces it. Worth a line in the self-hosting docs whenever that env var gets documented at all — it currently isn't, anywhere.

sub vs grytUserId as separate fields. They answer different questions: what the client signed its assertion with, and what this server files people under. One field would have to be right for both at once, which is exactly the conflation that caused this. join.ts uses grytUserId for storage and keeps sub for the assertion check and the identity-link proof, since those are values the client knows.

Two rejections that look paranoid and aren't. A CA sub starting with key: was already refused. A CA sub containing | is refused now — otherwise the primary issuer, whose users are stored bare, could mint a sub that lands exactly on another issuer's user. Keycloak mints UUIDs, so nothing legitimate sends either.

Dispatch instead of the loop. Certificates now match the issuer they name against the trusted list and verify once. Previously every trusted issuer was tried in turn, so a junk certificate cost a signature check per issuer on an unauthenticated pre-join path, and one unreachable issuer early in the list added its JWKS fetch timeout to every join behind it.

This changes an error message, and the existing test asserting /trusted issuers/i is updated to match. The behaviour is stricter in a good way — an untrusted issuer is now refused without any network contact at all.

Verified

yarn test — 63 pass, 0 fail. Six new cases, run against real JWKS endpoints on loopback rather than a closed port, because the property only exists on the far side of a successful CA verification:

  • the primary issuer's users are stored under a bare sub
  • every other trusted issuer is written into the id
  • two trusted CAs issuing the same sub produce different users — the actual attack
  • a sub that already looks qualified is refused
  • a CA sub in the self-signed namespace is refused
  • an untrusted issuer is rejected without contacting it

npx tsc --noEmit clean.

Not in this PR

Nothing about federation itself. This only makes it possible to trust a second CA without that CA being able to impersonate everyone — the trust policy, the UI for showing which issuer somebody came from, and whether the official server should accept foreign identities at all are all still open. Worth remembering that a federated identity from an uncurated issuer list is exactly as strong as a guest identity for moderation purposes, since whoever runs the CA can mint unlimited ones.

🤖 Generated with Claude Code

`verifyCertificate` already returned the issuer it verified against, and
`join.ts` threw it away: `grytUserId = cert.sub`, the bare Keycloak sub.

That is safe only while `GRYT_TRUSTED_CERT_ISSUERS` has one entry. With
two, a hostile CA mints a certificate whose sub is a UUID that already
exists here and inherits that user's roles, ownership, ban state and
message attribution — every one of those keys on `gryt_user_id`. The sub
is not a secret either: it is a claim in the certificate handed to every
server on every join, so an operator who has seen a user already has it.
Nothing has to be stolen.

The local tier has never had this problem, because its subs are derived
from the key and carry a `key:` prefix that a CA is refused. The account
tier had no equivalent between two CAs. It does now.

The first configured issuer owns the unqualified namespace and everything
else is prefixed with the issuer that vouched for it. That leaves existing
rows untouched — the alternative rewrites `gryt_user_id` across `users`,
`bans`, `server_config.owner_gryt_user_id` and `refresh_tokens` on live
servers that have no off-box backups, which is not a trade worth making
for a hole that is still latent. The issuer used is the one the signature
was checked against, never the one the certificate claimed, so a CA cannot
reach outside its own name by writing a different one down.

What it costs is an ordering hazard: moving the first entry in
`GRYT_TRUSTED_CERT_ISSUERS` changes who owns the unqualified namespace and
would silently re-point every account identity. Said as loudly as a
comment can say it.

`sub` and `grytUserId` are kept as separate fields rather than one. They
answer different questions — what the client signed, and what this server
files people under — and a single field would have to be right for both at
once, which is the confusion that allowed this in the first place.

Certificates now dispatch on the issuer they name, matched against the
trusted list, instead of being tried against each in turn. One issuer made
the difference invisible; more than one makes the loop a liability. A
rejected certificate used to cost a signature check per trusted issuer on
a path anybody can reach before joining, and one unreachable issuer early
in the list added its JWKS fetch timeout to every join behind it.

Tested against real JWKS endpoints on loopback rather than a closed port,
because the property only exists on the far side of a successful CA
verification. Six cases, including two trusted CAs issuing the same sub
and ending up as different users.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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