Skip to content

Re-mint stale-domain DIDs from the client, where the signing key actually lives - #216

Merged
brianorwhatever merged 1 commit into
mainfrom
fix/remint-did-client
Jul 29, 2026
Merged

Re-mint stale-domain DIDs from the client, where the signing key actually lives#216
brianorwhatever merged 1 commit into
mainfrom
fix/remint-did-client

Conversation

@brianorwhatever

@brianorwhatever brianorwhatever commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #215. Running that migration on prod surfaced a design error in it.

What went wrong

Turnkey error 3: organization mismatch: request is targeting organization
(20ed9d43-...), but voters are in organization (66cb8d23-...)

convex/didCreation.ts:createDIDWebVH — the function #215 built on — had no callers. It was dead code. The live path is useAuth.tsx:178createUserWebVHDid, which mints in the browser with an Ed25519 key from localStorage (getOrCreateKeyPair). schema.ts:48 says it outright: "created client-side".

So this was never a Turnkey policy problem. The server has never held the key, and can't mint a replacement the user would still control — re-minting with a fresh server key would hand back a DID they couldn't update.

The failure was clean: the mint runs before any rewrite and the identity row moves last, so prod data was verified untouched afterwards (all 375 rows still on the old DID).

What changed

  • Removed the unusable server-side action.
  • Kept applyRemint — the tested 17-table/24-field rewrite doesn't care where the new DID came from.
  • Mint moved to the browser, reusing the same localStorage key so the new DID has the same controller.
  • Triggered automatically on login via useDidDomainRemint, only when the DID's domain ≠ current WEBVH_DOMAIN.

Authorization — the part that needed care

applyRemint reassigns every row from one DID to another. Exposed naively that's an account-takeover primitive, and this app's other mutations do trust client-supplied DIDs. So remintDid:remintUserDid proves control instead:

  1. Resolves the submitted log rather than parsing it — resolveDIDFromLog verifies each entry's Data Integrity proof, so a log can't be forged without the key.
  2. Requires its update keys to intersect the stored log's keys for the old DID. Re-mint reuses the same key, so an honest client always matches and an attacker would need the victim's private key.
  3. Requires the new DID to land on this deployment's WEBVH_DOMAIN, so it can only ever move an identity onto the canonical domain.

Any failure throws before a row is touched.

Also fixes the original share-link bug

  • domainFromDid returned parts[3] undecoded, so a host with a port produced https://localhost%3A5173/... — an invalid host. Now decoded, and localhost gets http since it has no certificate.
  • currentWebvhDomain checks isNativePlatform() before VITE_WEBVH_DOMAIN. That env var is baked in at build time, so bun run cap:build (which reads .env.local) used to stamp a dev host into real native DIDs, overriding the boop.ad branch. That's plausibly how this class of bug happened in the first place.

Safety

useDidDomainRemint is explicitly temporary — a repair pass, not a compatibility layer — and never blocks login. On failure the old DID keeps working as an identifier (just unresolvable) and it retries next load.

Tests

5 new, covering the native-precedence regression, percent-decoding, both URL schemes, and that a non-did:webvh identifier never triggers a re-mint. Suite 114 pass / 0 fail; both typechecks clean; lint unchanged from baseline.

One of these tests initially leaked globalThis.window, which made unrelated test files believe they were in a browser — fixed by scoping the global to each call, so the suite stays honest under bun's shared-process runner.

After merge

Each of the 6 accounts re-mints itself on next login. Verify with:

npx convex run --prod migrations/remintUserDidDb:listUsersOnDomain '{"domain":"trypoo.app"}'

which should drain to [].

⚠️ Unchanged from #215: did:cel envelopes keep the old DID in their signed genesis content, so provenance will still show the old creator DID. Reconciling that is re-genesis, not a rewrite.

🤖 Generated with Claude Code

Note

Re-mint stale-domain DIDs from the client where the signing key lives

  • Adds a useDidDomainRemint hook that detects when a user's did:webvh references a stale domain, mints a new DID in the browser using the existing localStorage key, and calls the new api.remintDid.remintUserDid action to rewrite server-side records.
  • The server action verifies cryptographic proofs on the submitted DID log, checks that update keys overlap between old and new logs, and persists the new log only after authorization.
  • Adds currentWebvhDomain() to src/lib/webvh.ts to centralize domain selection — native builds now always use boop.ad regardless of VITE_WEBVH_DOMAIN.
  • Fixes domainFromDid to percent-decode the domain component (e.g. localhost%3A5173localhost:5173) and fixes buildListResourceUrl to use http for localhost.
  • Removes the old server-driven migrations/remintUserDid action and replaces it with new DB helpers (findUserByDid, getDidLogFor, storeDidLog) in convex/migrations/remintUserDidDb.ts.

Macroscope summarized f43ef8a.

#215 tried to re-mint server-side via didCreation:createDIDWebVH. That could
never work, and running it on prod proved it:

  Turnkey error 3: organization mismatch: request is targeting organization
  (20ed9d43-...), but voters are in organization (66cb8d23-...)

createDIDWebVH had no callers — it was dead code. The live path is
useAuth.tsx -> createUserWebVHDid, which mints in the BROWSER using an Ed25519
key in localStorage (schema.ts:48 says so: "created client-side"). The server has
never held that key, so it cannot mint a replacement the user would still
control. Removed the unusable action; the tested applyRemint rewrite is kept
as-is, since it does not care where the new DID came from.

The mint now happens in the browser, reusing the same localStorage key so the
new DID has the same controller.

Authorization: applyRemint reassigns every row from one DID to another, so
exposing it to clients naively would be an account-takeover primitive — and the
app's other mutations do trust client-supplied DIDs. remintDid:remintUserDid
instead RESOLVES the submitted log (resolveDIDFromLog verifies each entry's
Data Integrity proof, so it cannot be forged without the key), requires its
update keys to intersect the stored log's keys for the old DID, and requires the
new DID to land on this deployment's WEBVH_DOMAIN. Any failure throws before a
row is touched.

Also fixes the two bugs that produced the broken share link:
- domainFromDid returned parts[3] undecoded, so a host with a port yielded
  `https://localhost%3A5173/...` — an invalid host. Now decoded, and localhost
  gets http since it has no certificate.
- currentWebvhDomain checks isNativePlatform BEFORE VITE_WEBVH_DOMAIN. The env
  var is baked at build time, so `bun run cap:build` (which reads .env.local)
  used to stamp a dev host into real native DIDs, overriding the boop.ad branch.

useDidDomainRemint is explicitly temporary — a repair pass, not a compatibility
layer — and never blocks login: a failure leaves the old DID working as an
identifier, just unresolvable, and retries on the next load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brianorwhatever
brianorwhatever merged commit 7bbba38 into main Jul 29, 2026
7 checks passed
brianorwhatever added a commit that referenced this pull request Jul 29, 2026
#216 required the new DID log to be signed by the key controlling the old DID.
Signing in on prod showed that is unsatisfiable:

  Key did:key:z6MktvNdMfyjcW6eFDgALRK2cYbdEU9uxqRxLxBy65mRuXah
  is not authorized to update.

didwebvh-ts raises that when a log's proof key is absent from its updateKeys.
The browser's current localStorage key is not the key that minted the existing
DID — localStorage is per-origin, and the original key went with the origin the
DID was minted on. So the key-intersection check could never pass, and the hook
failed client-side before reaching Convex (which is why prod logs showed no
invocation).

There is no continuity left to preserve, so the re-mint now issues a genuinely
NEW identity and is authorized differently: POST /api/user/remintDid runs
requireAuth(), takes the account from the verified token, and rewrites only that
account's rows. The request body cannot name a victim.

This is strictly safer than the status quo rather than a relaxation:
/api/user/updateDID already lets an authenticated user set their DID to anything
and silently strands every row on the old value. Pairing the swap with
applyRemint closes that.

Also extracts getConvexHttpUrl into lib/convexUrls so the hook can reach HTTP
actions without importing the auth provider (which imports the hook).

The client reloads after a successful re-mint: the app is keyed by DID
throughout, so live subscriptions would otherwise hold rows under the old one.

Co-authored-by: Claude Opus 5 (1M context) <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