diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 687993b..73fff4c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -56,7 +56,6 @@ import type * as listsHttp from "../listsHttp.js"; import type * as migrations_bucketBackfill from "../migrations/bucketBackfill.js"; import type * as migrations_celAssetDids from "../migrations/celAssetDids.js"; import type * as migrations_celAssetDidsDb from "../migrations/celAssetDidsDb.js"; -import type * as migrations_remintUserDid from "../migrations/remintUserDid.js"; import type * as migrations_remintUserDidDb from "../migrations/remintUserDidDb.js"; import type * as notificationActions from "../notificationActions.js"; import type * as notifications from "../notifications.js"; @@ -66,6 +65,7 @@ import type * as presenceHttp from "../presenceHttp.js"; import type * as publication from "../publication.js"; import type * as rateLimits from "../rateLimits.js"; import type * as referrals from "../referrals.js"; +import type * as remintDid from "../remintDid.js"; import type * as siteActions from "../siteActions.js"; import type * as siteAssets from "../siteAssets.js"; import type * as siteInternals from "../siteInternals.js"; @@ -133,7 +133,6 @@ declare const fullApi: ApiFromModules<{ "migrations/bucketBackfill": typeof migrations_bucketBackfill; "migrations/celAssetDids": typeof migrations_celAssetDids; "migrations/celAssetDidsDb": typeof migrations_celAssetDidsDb; - "migrations/remintUserDid": typeof migrations_remintUserDid; "migrations/remintUserDidDb": typeof migrations_remintUserDidDb; notificationActions: typeof notificationActions; notifications: typeof notifications; @@ -143,6 +142,7 @@ declare const fullApi: ApiFromModules<{ publication: typeof publication; rateLimits: typeof rateLimits; referrals: typeof referrals; + remintDid: typeof remintDid; siteActions: typeof siteActions; siteAssets: typeof siteAssets; siteInternals: typeof siteInternals; diff --git a/convex/migrations/remintUserDid.ts b/convex/migrations/remintUserDid.ts deleted file mode 100644 index a932ae3..0000000 --- a/convex/migrations/remintUserDid.ts +++ /dev/null @@ -1,114 +0,0 @@ -"use node"; - -/** - * Re-mint a user's did:webvh onto the current WEBVH_DOMAIN. - * - * Why a re-mint and not an update: did:webvh puts the domain inside the - * identifier, and these DIDs are created `portable: false` (didCreation.ts:57), - * so per spec the domain cannot move. A DID minted while the app was served from - * an old domain names that domain forever — and since a did:webvh resolves by - * fetching did.jsonl from its own domain, once that domain stops serving, the - * DID is unresolvable and every list published under it fails to verify. - * - * The new DID reuses the SAME Turnkey key and the SAME slug - * (`user-{subOrgId:16}`), so only the domain and the derived SCID change. The - * user keeps their keys; only the identifier moves. - * - * Dry run first — it only counts rows: - * npx convex run --prod migrations/remintUserDid:preview '{"email":"..."}' - * - * Then: - * npx convex run --prod migrations/remintUserDid:remintByEmail '{"email":"..."}' - * - * Idempotent: a user whose DID already sits on WEBVH_DOMAIN is skipped. - */ - -import { v } from "convex/values"; -import { internalAction } from "../_generated/server"; -import { internal } from "../_generated/api"; -import type { Id } from "../_generated/dataModel"; - -type Candidate = { - _id: Id<"users">; - did: string; - email?: string; - turnkeySubOrgId?: string; -}; - -function requireDomain(): string { - const domain = process.env.WEBVH_DOMAIN; - if (!domain) throw new Error("WEBVH_DOMAIN is not set on this deployment"); - return domain; -} - -function domainOf(did: string): string { - const parts = did.split(":"); - if (parts.length < 5) throw new Error(`Not a did:webvh with a path: ${did}`); - return decodeURIComponent(parts[3]); -} - -/** Counts what a re-mint would touch. Changes nothing. */ -export const preview = internalAction({ - args: { email: v.string() }, - handler: async (ctx, args): Promise => { - const domain = requireDomain(); - const user: Candidate | null = await ctx.runQuery( - internal.migrations.remintUserDidDb.findUserByEmail, - { email: args.email } - ); - if (!user) throw new Error(`No user with email ${args.email}`); - - const current = domainOf(user.did); - const counts = await ctx.runQuery(internal.migrations.remintUserDidDb.previewRemint, { - oldDid: user.did, - }); - - return { - email: args.email, - currentDid: user.did, - currentDomain: current, - targetDomain: domain, - needsRemint: current !== domain, - rowsAffected: counts, - }; - }, -}); - -export const remintByEmail = internalAction({ - args: { email: v.string() }, - handler: async ( - ctx, - args - ): Promise<{ oldDid: string; newDid: string; rewritten: number } | { skipped: string }> => { - const domain = requireDomain(); - const user: Candidate | null = await ctx.runQuery( - internal.migrations.remintUserDidDb.findUserByEmail, - { email: args.email } - ); - if (!user) throw new Error(`No user with email ${args.email}`); - if (!user.turnkeySubOrgId) { - throw new Error(`User ${args.email} has no Turnkey sub-org; cannot re-mint`); - } - if (domainOf(user.did) === domain) { - return { skipped: `${user.did} is already on ${domain}` }; - } - - // Same key, same slug — only the domain (and derived SCID) change. - const { did: newDid }: { did: string } = await ctx.runAction( - internal.didCreation.createDIDWebVH, - { subOrgId: user.turnkeySubOrgId, email: args.email } - ); - - if (newDid === user.did) { - return { skipped: `re-mint produced the same DID (${newDid})` }; - } - - const { rewritten }: { rewritten: number; skipped: boolean } = await ctx.runMutation( - internal.migrations.remintUserDidDb.applyRemint, - { userId: user._id, oldDid: user.did, newDid } - ); - - console.log(`[remintUserDid] ${user.did} -> ${newDid} (${rewritten} rows rewritten)`); - return { oldDid: user.did, newDid, rewritten }; - }, -}); diff --git a/convex/migrations/remintUserDidDb.ts b/convex/migrations/remintUserDidDb.ts index 2fc8efd..0792e07 100644 --- a/convex/migrations/remintUserDidDb.ts +++ b/convex/migrations/remintUserDidDb.ts @@ -116,6 +116,53 @@ export const findUserByEmail = internalQuery({ }, }); +export const findUserByDid = internalQuery({ + args: { did: v.string() }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("users") + .withIndex("by_did", (q) => q.eq("did", args.did)) + .first(); + return user ? { _id: user._id, did: user.did!, email: user.email } : null; + }, +}); + +/** The stored did.jsonl for a DID — the authorization anchor for a re-mint. */ +export const getDidLogFor = internalQuery({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("didLogs") + .withIndex("by_user_did", (q) => q.eq("userDid", args.userDid)) + .first(); + return row ? { log: row.log, path: row.path } : null; + }, +}); + +export const storeDidLog = internalMutation({ + args: { userDid: v.string(), path: v.string(), log: v.string() }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("didLogs") + .withIndex("by_path", (q) => q.eq("path", args.path)) + .first(); + if (existing) { + await ctx.db.patch(existing._id, { + userDid: args.userDid, + log: args.log, + updatedAt: Date.now(), + }); + return; + } + await ctx.db.insert("didLogs", { + userDid: args.userDid, + path: args.path, + log: args.log, + updatedAt: Date.now(), + }); + }, +}); + /** Users whose DID sits on a domain other than the one we mint on today. */ export const listUsersOnDomain = internalQuery({ args: { domain: v.string() }, diff --git a/convex/remintDid.ts b/convex/remintDid.ts new file mode 100644 index 0000000..6378f64 --- /dev/null +++ b/convex/remintDid.ts @@ -0,0 +1,109 @@ +"use node"; + +/** + * Client-driven re-mint of a user's did:webvh onto the current WEBVH_DOMAIN. + * + * The mint has to happen in the browser: a user DID is signed by an Ed25519 key + * held in that browser's localStorage (see getOrCreateKeyPair in lib/webvh.ts), + * and the server has never held it. The server's job is to authorize the swap + * and rewrite the rows. + * + * AUTHORIZATION — this matters, because applyRemint reassigns every row owned by + * one DID to another, which is an account-takeover primitive if it is callable + * with arbitrary arguments. The app's other mutations trust client-supplied + * DIDs; this one must not. Instead: + * + * 1. The submitted log is RESOLVED, not merely parsed. resolveDIDFromLog + * verifies each entry's Data Integrity proof, so a log cannot be forged + * without the signing key. + * 2. Its update keys must intersect the stored log's update keys for oldDid. + * Re-mint reuses the same localStorage key, so the honest client always + * matches, while an attacker would need the victim's private key. + * 3. The new DID must land on this deployment's WEBVH_DOMAIN, so this can only + * ever move an identity onto the canonical domain. + * + * Any failure throws before a single row is touched. + */ + +import { v } from "convex/values"; +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { resolveDIDFromLog } from "didwebvh-ts"; +import type { DIDLog } from "didwebvh-ts"; + +function parseLog(jsonl: string): DIDLog { + const entries = jsonl + .trim() + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + if (entries.length === 0) throw new Error("DID log is empty"); + return entries as DIDLog; +} + +function domainOf(did: string): string | null { + const parts = did.split(":"); + if (parts.length < 5 || parts[1] !== "webvh") return null; + try { + return decodeURIComponent(parts[3]); + } catch { + return parts[3]; + } +} + +export const remintUserDid = action({ + args: { + oldDid: v.string(), + newDidLog: v.string(), + path: v.string(), + }, + handler: async ( + ctx, + args + ): Promise<{ newDid: string; rewritten: number } | { skipped: string }> => { + const targetDomain = process.env.WEBVH_DOMAIN; + if (!targetDomain) throw new Error("WEBVH_DOMAIN is not set on this deployment"); + + // (1) Resolve — this verifies every entry's proof, not just its shape. + const { did: newDid, meta: newMeta } = await resolveDIDFromLog(parseLog(args.newDidLog)); + + if (newDid === args.oldDid) return { skipped: "already on the current DID" }; + + // (3) Only ever move onto this deployment's canonical domain. + const newDomain = domainOf(newDid); + if (newDomain !== targetDomain) { + throw new Error(`Re-mint must target ${targetDomain}, got ${newDomain ?? "unknown"}`); + } + + const user = await ctx.runQuery(internal.migrations.remintUserDidDb.findUserByDid, { + did: args.oldDid, + }); + if (!user) throw new Error("No user holds that DID"); + + // (2) Prove the caller controls the key that controls the OLD DID. + const oldRecord = await ctx.runQuery(internal.migrations.remintUserDidDb.getDidLogFor, { + userDid: args.oldDid, + }); + if (!oldRecord) throw new Error("No stored DID log for the current DID; cannot authorize"); + + const { meta: oldMeta } = await resolveDIDFromLog(parseLog(oldRecord.log)); + const shared = newMeta.updateKeys.filter((k) => oldMeta.updateKeys.includes(k)); + if (shared.length === 0) { + throw new Error("New DID is not controlled by the current DID's update key"); + } + + const { rewritten }: { rewritten: number; skipped: boolean } = await ctx.runMutation( + internal.migrations.remintUserDidDb.applyRemint, + { userId: user._id, oldDid: args.oldDid, newDid } + ); + + await ctx.runMutation(internal.migrations.remintUserDidDb.storeDidLog, { + userDid: newDid, + path: args.path, + log: args.newDidLog, + }); + + console.log(`[remintDid] ${args.oldDid} -> ${newDid} (${rewritten} rows)`); + return { newDid, rewritten }; + }, +}); diff --git a/scripts/webvh-domain.test.mjs b/scripts/webvh-domain.test.mjs new file mode 100644 index 0000000..b4e41fe --- /dev/null +++ b/scripts/webvh-domain.test.mjs @@ -0,0 +1,128 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/webvh-domain-test"; +let scenario = 0; + +/** + * Builds each scenario to its OWN file: the `define` values differ per scenario, + * and a shared outfile plus a same-millisecond cache-buster let bun reuse a + * stale module. + */ +async function loadModule({ native, envDomain, host }) { + const id = ++scenario; + if (id === 1) { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + } + await build({ + entryPoints: ["src/lib/webvh.ts"], + outfile: `${outdir}/webvh-${id}.mjs`, + bundle: true, + // node, not neutral: didwebvh-ts's esm build imports node:module. The + // functions under test are pure string handling, so the platform is moot. + platform: "node", + format: "esm", + target: "es2022", + define: { + // JSON.stringify(undefined) is not a string; esbuild needs the literal. + "import.meta.env.VITE_WEBVH_DOMAIN": + envDomain === undefined ? "undefined" : JSON.stringify(envDomain), + }, + // Capacitor's platform check is the thing under test; stub it per scenario. + plugins: [ + { + name: "stub-capacitor", + setup(b) { + b.onResolve({ filter: /^@capacitor\/core$/ }, () => ({ + path: "capacitor-stub", + namespace: "stub", + })); + b.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ + contents: `export const Capacitor = { isNativePlatform: () => ${native} };`, + loader: "js", + })); + }, + }, + ], + }); + const mod = await import(pathToFileURL(`${process.cwd()}/${outdir}/webvh-${id}.mjs`).href); + // `window` is read at call time, so hand back a wrapper that installs it only + // for the duration of the call. bun shares globals across test files, and a + // leaked `window` makes unrelated modules believe they are in a browser. + return new Proxy(mod, { + get(target, prop) { + const value = target[prop]; + if (typeof value !== "function") return value; + return (...fnArgs) => { + const had = "window" in globalThis; + const previous = globalThis.window; + globalThis.window = { location: { host } }; + try { + return value(...fnArgs); + } finally { + if (had) globalThis.window = previous; + else delete globalThis.window; + } + }; + }, + }); +} + +test("native builds ignore a baked-in dev VITE_WEBVH_DOMAIN", async () => { + // The regression: `bun run cap:build` reads .env.local, so a dev host used to + // get stamped into real native DIDs. + const mod = await loadModule({ + native: true, + envDomain: "localhost:5173", + host: "localhost", + }); + assert.equal(mod.currentWebvhDomain(), "boop.ad"); +}); + +test("web builds still honour VITE_WEBVH_DOMAIN, then fall back to the host", async () => { + const withEnv = await loadModule({ + native: false, + envDomain: "localhost:5173", + host: "boop.ad", + }); + assert.equal(withEnv.currentWebvhDomain(), "localhost:5173"); + + const withoutEnv = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + assert.equal(withoutEnv.currentWebvhDomain(), "boop.ad"); +}); + +test("domainFromDid decodes a percent-encoded host", async () => { + const mod = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + assert.equal(mod.domainFromDid("did:webvh:QmS:localhost%3A5173:user-a"), "localhost:5173"); + assert.equal(mod.domainFromDid("did:webvh:QmS:boop.ad:user-a"), "boop.ad"); +}); + +test("buildListResourceUrl produces an openable URL for both hosts", async () => { + const mod = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + + assert.equal( + mod.buildListResourceUrl("did:webvh:QmS:boop.ad:user-a", "l1"), + "https://boop.ad/user-a/resources/list-l1" + ); + // Was `https://localhost%3A5173/...` — an invalid host and an unusable scheme. + assert.equal( + mod.buildListResourceUrl("did:webvh:QmS:localhost%3A5173:user-a", "l1"), + "http://localhost:5173/user-a/resources/list-l1" + ); +}); + +test("isStaleDidDomain flags only DIDs off the current domain", async () => { + const mod = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + + assert.equal(mod.isStaleDidDomain("did:webvh:QmS:trypoo.app:user-a"), true); + assert.equal(mod.isStaleDidDomain("did:webvh:QmS:boop.ad:user-a"), false); + assert.equal( + mod.isStaleDidDomain("did:temp:20ed9d43"), + false, + "a non-webvh DID must not trigger a re-mint" + ); +}); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 2cd2aff..0ee6775 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -27,6 +27,7 @@ import { type ReactNode, } from "react"; import { createUserWebVHDid } from "../lib/webvh"; +import { useDidDomainRemint } from "./useDidDomainRemint"; import { storageAdapter } from "../lib/storageAdapter"; import { identifyUser, resetAnalytics } from "../lib/analytics"; @@ -402,6 +403,9 @@ export function AuthProvider({ children }: AuthProviderProps) { await storageAdapter.remove(JWT_STORAGE_KEY); }, []); + // Repairs identities minted on a domain we no longer serve. Temporary. + useDidDomainRemint(user); + const value: AuthContextValue = { isAuthenticated: user !== null, isLoading, diff --git a/src/hooks/useDidDomainRemint.ts b/src/hooks/useDidDomainRemint.ts new file mode 100644 index 0000000..feeb4bb --- /dev/null +++ b/src/hooks/useDidDomainRemint.ts @@ -0,0 +1,64 @@ +/** + * One-shot repair for identities minted on a domain we no longer serve. + * + * A did:webvh encodes its domain in the identifier and ours are non-portable, + * so an identity minted while the app lived on an old host names that host + * forever — and since a did:webvh resolves by fetching did.jsonl from its own + * domain, those DIDs are unresolvable and every list published under them fails + * to verify. + * + * The mint must happen here, not on the server: the controller key lives in this + * browser's localStorage and nowhere else. The same key is reused, so the new + * DID has the same controller — that is also what authorizes the server swap. + * + * TEMPORARY. Delete once no account reports a stale domain; it is a repair pass, + * not a permanent compatibility layer. + */ + +import { useEffect, useRef } from "react"; +import { useAction } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import { createUserWebVHDid, isStaleDidDomain } from "../lib/webvh"; +import { Sentry } from "../lib/sentry"; + +export function useDidDomainRemint(user: { + did?: string; + email?: string; + turnkeySubOrgId?: string; +} | null) { + const remint = useAction(api.remintDid.remintUserDid); + // Guards against a second run while the first is in flight — the effect + // re-fires as `user` identity changes across renders. + const attempted = useRef(null); + + useEffect(() => { + const did = user?.did; + const email = user?.email; + const subOrgId = user?.turnkeySubOrgId; + if (!did || !email || !subOrgId) return; + if (!isStaleDidDomain(did)) return; + if (attempted.current === did) return; + attempted.current = did; + + void (async () => { + try { + // Same localStorage key, current domain — only the domain and SCID move. + const minted = await createUserWebVHDid({ email, subOrgId }); + const result = await remint({ + oldDid: did, + newDidLog: minted.didLogJsonl, + path: minted.path, + }); + if ("newDid" in result) { + console.info(`[remint] ${did} -> ${result.newDid} (${result.rewritten} rows)`); + } + } catch (err) { + // Never block login on this. The old DID keeps working as an identifier; + // it just stays unresolvable until a later attempt succeeds. + console.error("[remint] failed", err); + Sentry.captureException(err); + attempted.current = null; + } + })(); + }, [user?.did, user?.email, user?.turnkeySubOrgId, remint]); +} diff --git a/src/lib/webvh.ts b/src/lib/webvh.ts index 4b60132..d3d4042 100644 --- a/src/lib/webvh.ts +++ b/src/lib/webvh.ts @@ -132,6 +132,36 @@ export function pathFromDid(did: string): string { return parts.slice(4).join(":"); } +/** + * The domain a DID minted right now would carry. + * + * The native check comes FIRST on purpose. VITE_WEBVH_DOMAIN is baked in at + * build time, so a locally-built app (`bun run cap:build` reads .env.local) + * used to stamp a dev host like `localhost:5173` into real native DIDs, + * overriding the boop.ad branch. The env override is dev-only now. + */ +export function currentWebvhDomain(): string { + if (Capacitor.isNativePlatform()) return "boop.ad"; + return (import.meta.env.VITE_WEBVH_DOMAIN as string | undefined) || window.location.host; +} + +/** The domain encoded in a did:webvh, percent-decoded (`localhost%3A5173` → `localhost:5173`). */ +export function domainFromDidSafe(did: string): string | null { + const parts = did.split(":"); + if (parts.length < 5 || parts[1] !== "webvh") return null; + try { + return decodeURIComponent(parts[3]); + } catch { + return parts[3]; + } +} + +/** True when this DID names a domain we no longer mint on — its did.jsonl is unreachable. */ +export function isStaleDidDomain(did: string): boolean { + const domain = domainFromDidSafe(did); + return domain !== null && domain !== currentWebvhDomain(); +} + export async function createUserWebVHDid(params: { email: string; subOrgId: string; @@ -139,9 +169,7 @@ export async function createUserWebVHDid(params: { }) { const { privateKey, publicKeyMultibase } = await getOrCreateKeyPair(params.subOrgId); const signer = new BrowserWebVHSigner(privateKey, publicKeyMultibase); - const host = Capacitor.isNativePlatform() ? 'boop.ad' : window.location.host; - const domain = - params.domain || (import.meta.env.VITE_WEBVH_DOMAIN as string | undefined) || host; + const domain = params.domain || currentWebvhDomain(); const userSlug = toUserSlug(params.email, params.subOrgId); @@ -191,7 +219,9 @@ export function domainFromDid(did: string): string { if (parts.length < 4 || parts[1] !== "webvh") { throw new Error(`Cannot extract domain from DID: ${did}`); } - return parts[3]; + // A host with a port is stored percent-encoded (`localhost%3A5173`), because + // `:` is the DID's own separator. Decode it or the URL has an invalid host. + return decodeURIComponent(parts[3]); } /** @@ -209,5 +239,8 @@ export function buildListResourceDid(userDid: string, listId: string): string { export function buildListResourceUrl(userDid: string, listId: string): string { const domain = domainFromDid(userDid); const path = pathFromDid(userDid); - return `https://${domain}/${path}/resources/list-${listId}`; + // did:webvh mandates https; local hosts have no certificate, so dev links + // would be unopenable otherwise. + const scheme = /^(localhost|127\.0\.0\.1)(:|$)/.test(domain) ? "http" : "https"; + return `${scheme}://${domain}/${path}/resources/list-${listId}`; }