fix(did): authorize DID log writes - #222
Merged
Merged
Conversation
…id.jsonl
A did:webvh log is what the world resolves to learn a user's keys, and the
write path had two independent doors open:
1. POST /api/did/log checked only that the Authorization header *started
with* "Bearer ". The token was never verified and `userDid` came from the
request body, unbound to the caller. `Authorization: Bearer x` passed.
2. `api.didLogs.upsertDidLog` was a public mutation, so a caller could skip
the HTTP handler entirely. VITE_CONVEX_URL ships in the browser bundle.
Either one let anyone overwrite or blank any user's served DID log. The SCID is
self-certifying, so a verifying resolver rejects a forged log rather than
accepting a fake identity — the exposure is defacement and DoS of resolution
for every honest consumer, with an attacker-controlled path→log mapping.
Present since 00e4234 (2026-02-19), not a regression from the Turnkey work.
The binding needs no new state: the client derives its serving path as
`user-<first 16 of subOrgId>` (toUserSlug) and mints
did:webvh:<scid>:<domain>:<path>, so both are recomputable from the JWT's
`sub` alone. assertDidLogOwnership recomputes them and rejects a mismatch —
the body is checked, never trusted. It matches the DID's trailing segment as
`:<slug>` rather than parsing by index, so a dev domain carrying a port
(localhost%3A5173) still binds and `evil<slug>` still doesn't.
Also pins iss/aud/exp in verifyAuthToken. signAuthToken has set issuer
`originals-auth` and audience `originals-api` since 2026-04-04 and it is the
only minter, so every live token (30d max) already carries them — no one gets
logged out. Without the pin, any other HS256 token sharing JWT_SECRET, or one
with no expiry at all, passed as a session.
Reads are untouched: GET /api/did/log and getDidLogByPath stay public, and
existing rows keep resolving. The one client caller (useAuth.tsx:195) already
sends the token, so no client change is needed.
didLogAuth duplicates toUserSlug because Convex modules cannot import from
src/; the test asserts the two stay in agreement.
Pre-existing typecheck failures in didCreation.ts, lib/turnkeySigner.ts and
siteActions.ts (didwebvh-ts/noble type drift) are untouched and unrelated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#217 added a second way into the didLogs table. /api/user/remintDid runs requireAuth(), so the account it rewrites is taken from the token — but the `path` it hands to remintUserDidDb.storeDidLog comes straight from the body, and that mutation patches whichever row matches `path`. So an authenticated user could post their own freshly minted log with `path: "user-<victim's first 16>"` and take over what the world resolves for that account. Weaker than the unauthenticated hole in the previous commit — it costs an account — but the same defacement primitive, and it landed after that fix was written. assertDidLogOwnership runs before applyRemint rather than next to the write: rejecting afterwards would leave every row already moved to the new DID with the log write refused. The test now also scans convex/ and requires any file running a didLogs-writing mutation to import the check. The helper's unit tests were all passing while this door stood open — a new call site is exactly the regression they cannot see. Verified non-vacuous: it matches userHttp.ts and didLogsHttp.ts today. Also corrects a stale reference in jwt.ts — the minter is signJwtToken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Anyone could overwrite or blank any user's served
did.jsonl. Three doors, all closed here.The exposure
A did:webvh log is what the world resolves to learn a user's keys. The write path took the target identity on trust:
POST /api/did/logwas effectively unauthenticated. It checked only that theAuthorizationheader started with"Bearer ". The token was never verified, anduserDidcame from the request body, unbound to the caller.Authorization: Bearer xpassed.api.didLogs.upsertDidLogwas a public mutation, so a caller could skip the HTTP handler entirely.VITE_CONVEX_URLships in the browser bundle.POST /api/user/remintDid(added in Authorize DID re-mint with the JWT, not key continuity #217) tookpathfrom the body. It does runrequireAuth(), so the account comes from the token — butremintUserDidDb.storeDidLogpatches whicheverdidLogsrow matchespath. An authenticated user could post their own freshly minted log underpath: "user-<victim's first 16>".The SCID is self-certifying, so a verifying resolver rejects a forged log rather than accepting a fake identity. The exposure is defacement and DoS of resolution for every honest consumer, with an attacker-controlled path→log mapping.
Doors 1 and 2 have been open since 00e4234 (2026-02-19) — not a regression from the Turnkey work. Door 3 arrived with #217, after the first two were already fixed on this branch.
The fix
The binding needs no new state. The client derives its serving path as
user-<first 16 of subOrgId>(toUserSlug) and mintsdid:webvh:<scid>:<domain>:<path>, so both are recomputable from the JWT'ssubalone.assertDidLogOwnershiprecomputes them and rejects a mismatch — the body is checked, never trusted.It matches the DID's trailing segment as
:<slug>rather than parsing by index, so a dev domain carrying a port (localhost%3A5173) still binds andevil<slug>still doesn't.upsertDidLogbecomes aninternalMutation. On the re-mint path the check runs beforeapplyRemint— rejecting afterwards would leave every row already moved to the new DID with the log write refused.Also pins
iss/aud/expinverifyAuthToken.signJwtTokenhas set issueroriginals-authand audienceoriginals-apisince 2026-04-04 and is the only minter, so every live token (30d max) already carries them — no one gets logged out. Without the pin, any other HS256 token sharingJWT_SECRET, or one with no expiry at all, passed as a session.Blast radius
GET /api/did/logandgetDidLogByPathstay public, and existing rows keep resolving.useAuth.tsx:186,useDidDomainRemint.ts) already send the token and already derivepathfromsubOrgId, so no client change is needed.didLogAuthduplicatestoUserSlugbecause Convex modules cannot import fromsrc/; the test asserts the two stay in agreement.Tests
scripts/did-log-auth.test.mjscovers claim pinning, the slug mirror, the honest client (including the port case), and the attacks.It also scans
convex/and requires any file running a didLogs-writing mutation to import the ownership check. The helper's unit tests were all passing while door 3 stood open — a new call site is exactly the regression they cannot see. Verified non-vacuous: the scan matchesuserHttp.tsanddidLogsHttp.tstoday.npx tsc -bandeslintare clean.🤖 Generated with Claude Code