Skip to content

fix(did): authorize DID log writes - #222

Merged
brianorwhatever merged 2 commits into
mainfrom
fix/did-log-auth
Aug 16, 2026
Merged

fix(did): authorize DID log writes#222
brianorwhatever merged 2 commits into
mainfrom
fix/did-log-auth

Conversation

@brianorwhatever

Copy link
Copy Markdown
Contributor

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:

  1. POST /api/did/log was effectively unauthenticated. It 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.
  3. POST /api/user/remintDid (added in Authorize DID re-mint with the JWT, not key continuity #217) took path from the body. It does run requireAuth(), so the account comes from the token — but remintUserDidDb.storeDidLog patches whichever didLogs row matches path. An authenticated user could post their own freshly minted log under path: "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 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.

upsertDidLog becomes an internalMutation. On the re-mint path the check runs before applyRemint — rejecting afterwards would leave every row already moved to the new DID with the log write refused.

Also pins iss/aud/exp in verifyAuthToken. signJwtToken has set issuer originals-auth and audience originals-api since 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 sharing JWT_SECRET, or one with no expiry at all, passed as a session.

Blast radius

  • Reads are untouched: GET /api/did/log and getDidLogByPath stay public, and existing rows keep resolving.
  • Both client write paths (useAuth.tsx:186, useDidDomainRemint.ts) already send the token and already derive path from subOrgId, so no client change is needed.
  • didLogAuth duplicates toUserSlug because Convex modules cannot import from src/; the test asserts the two stay in agreement.

Tests

scripts/did-log-auth.test.mjs covers 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 matches userHttp.ts and didLogsHttp.ts today.

npx tsc -b and eslint are clean.

🤖 Generated with Claude Code

brianorwhatever and others added 2 commits August 16, 2026 00:33
…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>
@brianorwhatever
brianorwhatever merged commit 207a2d1 into main Aug 16, 2026
6 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