diff --git a/apps/api/package.json b/apps/api/package.json index 6e49667..9069b20 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "test": "vitest run", "db:generate": "drizzle-kit generate", "db:migrate:local": "wrangler d1 migrations apply trust0-db --local", "db:migrate:remote": "wrangler d1 migrations apply trust0-db --remote" @@ -21,6 +22,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20241230.0", "drizzle-kit": "^0.30.0", + "vitest": "^4.0.0", "wrangler": "^4.14.0" } } diff --git a/apps/api/src/__tests__/policy.test.ts b/apps/api/src/__tests__/policy.test.ts new file mode 100644 index 0000000..5e5fea2 --- /dev/null +++ b/apps/api/src/__tests__/policy.test.ts @@ -0,0 +1,136 @@ +import { computeLinkHash, createChainLink, generateIdentityKey, verifyChain } from "@trust0/identity"; +import { describe, expect, it } from "vitest"; +import { + assertAppendAuthorized, + assertProfileMatchesChainState, + classifyProfileUpdate, +} from "../policy"; + +async function makeIdentity() { + const { privateKey, publicJWK } = await generateIdentityKey(); + const { computeFingerprint } = await import("@trust0/identity"); + const fingerprint = await computeFingerprint(publicJWK); + return { privateKey, publicJWK, fingerprint }; +} + +async function buildRotatedState() { + const key1 = await makeIdentity(); + const key2 = await makeIdentity(); + + const genesis = await createChainLink({ + seqno: 0, + prev: null, + type: "key_init", + body: { fingerprint: key1.fingerprint }, + key: key1.privateKey, + publicJWK: key1.publicJWK, + fingerprint: key1.fingerprint, + }); + + const rotate = await createChainLink({ + seqno: 1, + prev: await computeLinkHash(genesis), + type: "key_rotate", + body: { new_fingerprint: key2.fingerprint }, + key: key1.privateKey, + publicJWK: key1.publicJWK, + fingerprint: key1.fingerprint, + }); + + const update = await createChainLink({ + seqno: 2, + prev: await computeLinkHash(rotate), + type: "profile_update", + body: { profile_fingerprint: key2.fingerprint }, + key: key2.privateKey, + publicJWK: key2.publicJWK, + fingerprint: key2.fingerprint, + }); + + const state = await verifyChain([genesis, rotate, update]); + return { key1, key2, state }; +} + +async function buildRevokedState() { + const key1 = await makeIdentity(); + const key2 = await makeIdentity(); + + const genesis = await createChainLink({ + seqno: 0, + prev: null, + type: "key_init", + body: { fingerprint: key1.fingerprint }, + key: key1.privateKey, + publicJWK: key1.publicJWK, + fingerprint: key1.fingerprint, + }); + + const rotate = await createChainLink({ + seqno: 1, + prev: await computeLinkHash(genesis), + type: "key_rotate", + body: { new_fingerprint: key2.fingerprint }, + key: key1.privateKey, + publicJWK: key1.publicJWK, + fingerprint: key1.fingerprint, + }); + + const update = await createChainLink({ + seqno: 2, + prev: await computeLinkHash(rotate), + type: "profile_update", + body: { profile_fingerprint: key2.fingerprint }, + key: key2.privateKey, + publicJWK: key2.publicJWK, + fingerprint: key2.fingerprint, + }); + + const revoke = await createChainLink({ + seqno: 3, + prev: await computeLinkHash(update), + type: "key_revoke", + body: { fingerprint: key1.fingerprint }, + key: key2.privateKey, + publicJWK: key2.publicJWK, + fingerprint: key2.fingerprint, + }); + + const state = await verifyChain([genesis, rotate, update, revoke]); + return { key1, key2, state }; +} + +describe("api policy", () => { + it("allows appends from a rotated active key", async () => { + const { key2, state } = await buildRotatedState(); + expect(() => assertAppendAuthorized(state, key2.fingerprint)).not.toThrow(); + }); + + it("rejects appends from an inactive key", async () => { + const { key1, state } = await buildRevokedState(); + expect(() => assertAppendAuthorized(state, key1.fingerprint)).toThrow( + "Signer is not an active key for this identity", + ); + }); + + it("classifies a rotated-key profile update as valid", async () => { + const { key1, key2, state } = await buildRotatedState(); + expect(classifyProfileUpdate(state, key1.fingerprint, key2.fingerprint)).toBe( + "rotated-key", + ); + }); + + it("rejects a rotated-key profile update from an inactive signer", async () => { + const { key1, state } = await buildRotatedState(); + const outsider = await makeIdentity(); + expect(() => + classifyProfileUpdate(state, key1.fingerprint, outsider.fingerprint), + ).toThrow("Signer is not an active key for this identity"); + }); + + it("rejects imported/current profiles that do not match chain state", async () => { + const { key1, state } = await buildRotatedState(); + expect(() => assertProfileMatchesChainState(state, key1.fingerprint)).toThrow( + "Profile fingerprint does not match the sigchain's current profile", + ); + }); +}); diff --git a/apps/api/src/aspe.ts b/apps/api/src/aspe.ts index b448a2c..3669c38 100644 --- a/apps/api/src/aspe.ts +++ b/apps/api/src/aspe.ts @@ -4,14 +4,29 @@ import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; import { cors } from "hono/cors"; import * as schema from "./db/schema"; +import { + loadVerifiedChainState, +} from "./identity-state"; import { type AuthEnv, requireAuth, sessionMiddleware, } from "./middleware/session"; +import { + assertProfileMatchesChainState, + classifyProfileUpdate, +} from "./policy"; const aspe = new Hono(); +const aspeUriFor = (domain: string | undefined, fingerprint: string): string => + `aspe:${domain || "trust0.app"}:${fingerprint}`; + +const parseAspeUriFingerprint = (aspeUri: string): string | null => { + const match = aspeUri.match(/^aspe:[^:]+:([A-Z2-7]+)$/i); + return match?.[1]?.toUpperCase() ?? null; +}; + // SPEC EXTENSION (APC-005): Ariadne spec does not mention CORS. // Browser-based ASPE clients need CORS headers for cross-origin fetch. // See: dev/spec/proposed-changes.md#apc-005 @@ -75,7 +90,7 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => { return c.json({ error: `Invalid request: ${(err as Error).message}` }, 400); } - const { action, fingerprint, profileJws } = request; + const { action, fingerprint, profileJws, aspeUri } = request; const now = new Date(); if (action === "create") { @@ -127,13 +142,16 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => { updatedAt: now, }); - return c.json({ fingerprint, uri: `aspe:${fingerprint}` }, 201); + return c.json({ fingerprint, uri: aspeUriFor(c.env.ASPE_DOMAIN, fingerprint) }, 201); } if (action === "update") { if (!profileJws) { return c.json({ error: "profile_jws required for update" }, 400); } + if (!aspeUri) { + return c.json({ error: "aspe_uri required for update" }, 400); + } try { const profile = await parseProfile(profileJws); @@ -147,10 +165,15 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => { ); } + const targetFingerprint = parseAspeUriFingerprint(aspeUri); + if (!targetFingerprint) { + return c.json({ error: "Invalid aspe_uri" }, 400); + } + const [existing] = await db .select() .from(schema.cryptoProfile) - .where(eq(schema.cryptoProfile.fingerprint, fingerprint)) + .where(eq(schema.cryptoProfile.fingerprint, targetFingerprint)) .limit(1); if (!existing) { @@ -161,13 +184,62 @@ aspe.post("/.well-known/aspe/post/", requireAuth, async (c) => { return c.json({ error: "Not authorized to update this profile" }, 403); } - await db - .update(schema.cryptoProfile) - .set({ profileJws, updatedAt: now }) - .where(eq(schema.cryptoProfile.fingerprint, fingerprint)); + if (existing.fingerprint === fingerprint) { + await db + .update(schema.cryptoProfile) + .set({ profileJws, updatedAt: now }) + .where(eq(schema.cryptoProfile.fingerprint, existing.fingerprint)); + } else { + if (!existing.identityId) { + return c.json( + { error: "Cannot rotate profile keys before the sigchain is initialized" }, + 400, + ); + } - return c.json({ fingerprint, uri: `aspe:${fingerprint}` }); - } + const { state } = await loadVerifiedChainState(db, existing.identityId); + try { + classifyProfileUpdate(state, existing.fingerprint, fingerprint); + } catch (err) { + return c.json({ error: (err as Error).message }, 403); + } + + const [conflictingProfile] = await db + .select() + .from(schema.cryptoProfile) + .where(eq(schema.cryptoProfile.fingerprint, fingerprint)) + .limit(1); + + if (conflictingProfile) { + return c.json({ error: "A profile already exists for the new fingerprint" }, 409); + } + + await db.insert(schema.cryptoProfile).values({ + fingerprint, + profileJws, + userId: user.id, + identityId: existing.identityId, + createdAt: existing.createdAt, + updatedAt: now, + }); + + await db + .update(schema.username) + .set({ fingerprint }) + .where(eq(schema.username.fingerprint, existing.fingerprint)); + + await db + .update(schema.attestation) + .set({ fingerprint }) + .where(eq(schema.attestation.fingerprint, existing.fingerprint)); + + await db + .delete(schema.cryptoProfile) + .where(eq(schema.cryptoProfile.fingerprint, existing.fingerprint)); + } + + return c.json({ fingerprint, uri: aspeUriFor(c.env.ASPE_DOMAIN, fingerprint) }); + } if (action === "delete") { const [existing] = await db @@ -429,7 +501,7 @@ aspe.post("/api/identity/email/challenge", requireAuth, async (c) => { `, }); - return c.json({ challenge, email: user.email, expiresAt: expiresAt.toISOString() }); + return c.json({ email: user.email, expiresAt: expiresAt.toISOString() }); }); // Step 2: Verify signed challenge and create attestation diff --git a/apps/api/src/export.ts b/apps/api/src/export.ts index 5638839..b1adaa1 100644 --- a/apps/api/src/export.ts +++ b/apps/api/src/export.ts @@ -1,7 +1,14 @@ +import { + computeLinkHash, + parseChainLink, + parseProfile, + verifyChain, +} from "@trust0/identity"; import { asc, eq } from "drizzle-orm"; import { Hono } from "hono"; import * as schema from "./db/schema"; import { type AuthEnv, requireAuth, sessionMiddleware } from "./middleware/session"; +import { assertProfileMatchesChainState } from "./policy"; const exportApi = new Hono(); @@ -99,6 +106,20 @@ exportApi.post("/api/identity/import", requireAuth, async (c) => { return c.json({ error: "Unsupported export version" }, 400); } + let parsedProfile; + try { + parsedProfile = await parseProfile(body.profile.profileJws); + } catch (err) { + return c.json( + { error: `Invalid profile: ${(err as Error).message}` }, + 400, + ); + } + + if (parsedProfile.fingerprint !== body.profile.fingerprint) { + return c.json({ error: "Profile fingerprint mismatch" }, 400); + } + // Check user doesn't already have a profile const [existing] = await db .select() @@ -110,61 +131,128 @@ exportApi.post("/api/identity/import", requireAuth, async (c) => { return c.json({ error: "User already has a profile. Delete it first to import." }, 409); } + const [existingFingerprint] = await db + .select() + .from(schema.cryptoProfile) + .where(eq(schema.cryptoProfile.fingerprint, parsedProfile.fingerprint)) + .limit(1); + + if (existingFingerprint) { + return c.json({ error: "Profile fingerprint already exists on this instance." }, 409); + } + + let verifiedIdentityId: string | null = null; + const normalizedChain: Array<{ + id: string; + fingerprint: string; + seqno: number; + type: string; + linkJws: string; + prevHash: string | null; + }> = []; + + if (body.chain.length > 0) { + let parsedLinks; + try { + parsedLinks = await Promise.all( + body.chain.map(async (link) => { + const parsed = await parseChainLink(link.linkJws); + const linkHash = await computeLinkHash(link.linkJws); + return { parsed, linkHash, linkJws: link.linkJws }; + }), + ); + } catch (err) { + return c.json( + { error: `Invalid chain link: ${(err as Error).message}` }, + 400, + ); + } + + parsedLinks.sort((a, b) => a.parsed.seqno - b.parsed.seqno); + + let chainState; + try { + chainState = await verifyChain(parsedLinks.map((link) => link.linkJws)); + } catch (err) { + return c.json( + { error: `Invalid sigchain: ${(err as Error).message}` }, + 400, + ); + } + + verifiedIdentityId = chainState.identityId; + + if (body.profile.identityId && body.profile.identityId !== verifiedIdentityId) { + return c.json({ error: "Imported identity ID does not match sigchain" }, 400); + } + + try { + assertProfileMatchesChainState(chainState, parsedProfile.fingerprint); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } + + normalizedChain.push( + ...parsedLinks.map(({ parsed, linkHash, linkJws }) => ({ + id: linkHash, + fingerprint: parsed.fingerprint, + seqno: parsed.seqno, + type: parsed.type, + linkJws, + prevHash: parsed.prev, + })), + ); + } else if (body.profile.identityId) { + return c.json( + { error: "Cannot import identity_id without a verifiable sigchain" }, + 400, + ); + } + + if (verifiedIdentityId) { + const [existingIdentity] = await db + .select() + .from(schema.sigchainLink) + .where(eq(schema.sigchainLink.identityId, verifiedIdentityId)) + .limit(1); + + if (existingIdentity) { + return c.json({ error: "Identity already exists on this instance." }, 409); + } + } + const now = new Date(); // Import profile await db.insert(schema.cryptoProfile).values({ - fingerprint: body.profile.fingerprint, + fingerprint: parsedProfile.fingerprint, profileJws: body.profile.profileJws, userId: user.id, - identityId: body.profile.identityId ?? null, + identityId: verifiedIdentityId, createdAt: now, updatedAt: now, }); // Import chain links - if (body.chain.length > 0 && body.profile.identityId) { - for (const link of body.chain) { - const { computeLinkHash } = await import("@trust0/identity"); - const linkHash = await computeLinkHash(link.linkJws); - - await db - .insert(schema.sigchainLink) - .values({ - id: linkHash, - identityId: body.profile.identityId, - fingerprint: body.profile.fingerprint, - seqno: link.seqno, - linkType: link.type, - linkJws: link.linkJws, - prevHash: null, // Could reconstruct, but the JWS contains it - createdAt: now, - }) - .onConflictDoNothing(); - } + for (const link of normalizedChain) { + await db.insert(schema.sigchainLink).values({ + id: link.id, + identityId: verifiedIdentityId!, + fingerprint: link.fingerprint, + seqno: link.seqno, + linkType: link.type, + linkJws: link.linkJws, + prevHash: link.prevHash, + createdAt: now, + }); } - // Import attestations - if (body.attestations) { - for (const att of body.attestations) { - const id = `${att.type}_${body.profile.fingerprint}`; - await db - .insert(schema.attestation) - .values({ - id, - fingerprint: body.profile.fingerprint, - type: att.type, - platform: att.platform ?? null, - platformUsername: att.platformUsername ?? null, - value: att.value, - attestedBy: att.attestedBy, - attestedAt: new Date(att.attestedAt), - }) - .onConflictDoNothing(); - } - } - - return c.json({ imported: true, fingerprint: body.profile.fingerprint }, 201); + return c.json({ + imported: true, + fingerprint: parsedProfile.fingerprint, + identityId: verifiedIdentityId, + ignoredAttestations: body.attestations?.length ?? 0, + }, 201); }); export { exportApi as exportRoutes }; diff --git a/apps/api/src/identity-state.ts b/apps/api/src/identity-state.ts new file mode 100644 index 0000000..b60892a --- /dev/null +++ b/apps/api/src/identity-state.ts @@ -0,0 +1,50 @@ +import { verifyChain, type ChainState } from "@trust0/identity"; +import { asc, and, eq } from "drizzle-orm"; +import type { DrizzleD1Database } from "drizzle-orm/d1"; +import * as schema from "./db/schema"; + +export type ApiDb = DrizzleD1Database; + +export async function loadVerifiedChainState( + db: ApiDb, + identityId: string, +): Promise<{ + links: Array; + state: ChainState; +}> { + const links = await db + .select() + .from(schema.sigchainLink) + .where(eq(schema.sigchainLink.identityId, identityId)) + .orderBy(asc(schema.sigchainLink.seqno)); + + if (links.length === 0) { + throw new Error("Chain not found"); + } + + const state = await verifyChain( + links.map((link) => link.linkJws), + identityId, + ); + + return { links, state }; +} + +export async function findOwnedProfileByIdentityId( + db: ApiDb, + userId: string, + identityId: string, +) { + const [profile] = await db + .select() + .from(schema.cryptoProfile) + .where( + and( + eq(schema.cryptoProfile.userId, userId), + eq(schema.cryptoProfile.identityId, identityId), + ), + ) + .limit(1); + + return profile ?? null; +} diff --git a/apps/api/src/policy.ts b/apps/api/src/policy.ts new file mode 100644 index 0000000..1f3865b --- /dev/null +++ b/apps/api/src/policy.ts @@ -0,0 +1,45 @@ +import type { ChainState } from "@trust0/identity"; + +export function assertAppendAuthorized( + state: ChainState, + signerFingerprint: string, +): void { + if (!state.activeFingerprints.has(signerFingerprint)) { + throw new Error("Signer is not an active key for this identity"); + } +} + +export function classifyProfileUpdate( + state: ChainState, + currentFingerprint: string, + signerFingerprint: string, +): "same-key" | "rotated-key" { + if (currentFingerprint === signerFingerprint) { + return "same-key"; + } + + if (!state.activeFingerprints.has(signerFingerprint)) { + throw new Error("Signer is not an active key for this identity"); + } + + return "rotated-key"; +} + +export function assertProfileMatchesChainState( + state: ChainState, + profileFingerprint: string, +): void { + if ( + state.currentProfileFingerprint !== null && + state.currentProfileFingerprint !== profileFingerprint + ) { + throw new Error("Profile fingerprint does not match the sigchain's current profile"); + } + + if ( + state.currentProfileFingerprint === null && + !state.activeFingerprints.has(profileFingerprint) + ) { + throw new Error("Profile fingerprint is not authorized by the sigchain"); + } +} diff --git a/apps/api/src/sigchain.ts b/apps/api/src/sigchain.ts index b8132cf..51b0adf 100644 --- a/apps/api/src/sigchain.ts +++ b/apps/api/src/sigchain.ts @@ -3,11 +3,17 @@ import { computeLinkHash, type ParsedChainLink, parseChainLink, + verifyChain, } from "@trust0/identity"; -import { asc, desc, eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { Hono } from "hono"; import * as schema from "./db/schema"; +import { + findOwnedProfileByIdentityId, + loadVerifiedChainState, +} from "./identity-state"; import { type AuthEnv, sessionMiddleware } from "./middleware/session"; +import { assertAppendAuthorized } from "./policy"; const sigchain = new Hono(); @@ -145,6 +151,15 @@ sigchain.post("/api/identity/chain/init", async (c) => { ); } + try { + await verifyChain([body.genesisLinkJws], identityId); + } catch (err) { + return c.json( + { error: `Invalid genesis chain state: ${(err as Error).message}` }, + 400, + ); + } + const now = new Date(); await db.insert(schema.sigchainLink).values({ @@ -186,52 +201,43 @@ sigchain.post("/api/identity/chain/append", async (c) => { return c.json( { error: `Invalid chain link: ${(err as Error).message}` }, 400, - ); - } - - // Validate fingerprint ownership - const [profile] = await db - .select() - .from(schema.cryptoProfile) - .where(eq(schema.cryptoProfile.fingerprint, parsed.fingerprint)) - .limit(1); - - if (!profile) { - return c.json({ error: "Crypto profile not found for this key" }, 404); - } - - if (profile.userId !== user.id) { - return c.json({ error: "Forbidden" }, 403); + ); } - if (!profile.identityId) { + if (parsed.seqno === 0) { return c.json( - { error: "Chain not initialized. Call /api/identity/chain/init first." }, + { error: "Genesis links must be created via /api/identity/chain/init" }, 400, ); } - const identityId = profile.identityId; - - // Get the last link in the chain const [lastLink] = await db .select() .from(schema.sigchainLink) - .where(eq(schema.sigchainLink.identityId, identityId)) - .orderBy(desc(schema.sigchainLink.seqno)) + .where(eq(schema.sigchainLink.id, parsed.prev!)) .limit(1); if (!lastLink) { return c.json( - { error: "Chain has no links. This should not happen." }, + { error: "prev hash does not match a known link in the chain" }, 400, ); } + const identityId = lastLink.identityId; + + const ownerProfile = await findOwnedProfileByIdentityId(db, user.id, identityId); + if (!ownerProfile) { + return c.json({ error: "Forbidden" }, 403); + } + + const { links, state } = await loadVerifiedChainState(db, identityId); + const currentLastLink = links[links.length - 1]; + // Validate seqno continuity - if (parsed.seqno !== lastLink.seqno + 1) { + if (parsed.seqno !== currentLastLink.seqno + 1) { return c.json( - { error: `Expected seqno ${lastLink.seqno + 1}, got ${parsed.seqno}` }, + { error: `Expected seqno ${currentLastLink.seqno + 1}, got ${parsed.seqno}` }, 400, ); } @@ -239,7 +245,7 @@ sigchain.post("/api/identity/chain/append", async (c) => { // Validate prev hash let expectedPrevHash: string; try { - expectedPrevHash = await computeLinkHash(lastLink.linkJws); + expectedPrevHash = await computeLinkHash(currentLastLink.linkJws); } catch (err) { return c.json( { error: `Failed to compute prev hash: ${(err as Error).message}` }, @@ -254,6 +260,21 @@ sigchain.post("/api/identity/chain/append", async (c) => { ); } + try { + assertAppendAuthorized(state, parsed.fingerprint); + } catch (err) { + return c.json({ error: (err as Error).message }, 403); + } + + try { + await verifyChain([...links.map((link) => link.linkJws), body.linkJws], identityId); + } catch (err) { + return c.json( + { error: `Invalid sigchain transition: ${(err as Error).message}` }, + 400, + ); + } + let linkHash: string; try { linkHash = await computeLinkHash(body.linkJws); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index 51f384a..b2ac620 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -1,6 +1,7 @@ name = "trust0-api" main = "src/index.ts" compatibility_date = "2024-12-30" +compatibility_flags = ["nodejs_compat"] [[d1_databases]] binding = "DB" diff --git a/apps/web/src/lib/browser-empty-fs.ts b/apps/web/src/lib/browser-empty-fs.ts new file mode 100644 index 0000000..74a3781 --- /dev/null +++ b/apps/web/src/lib/browser-empty-fs.ts @@ -0,0 +1,3 @@ +export function writeFile(_file: string, _data: string, callback: (error?: Error) => void) { + callback(new Error("fs.writeFile is unavailable in the browser")); +} diff --git a/apps/web/src/lib/browser-irc-upd.ts b/apps/web/src/lib/browser-irc-upd.ts new file mode 100644 index 0000000..30a7cb5 --- /dev/null +++ b/apps/web/src/lib/browser-irc-upd.ts @@ -0,0 +1,9 @@ +class BrowserIrcClient { + constructor() { + throw new Error("IRC verification requires the verification proxy in the browser"); + } +} + +export default { + Client: BrowserIrcClient, +}; diff --git a/apps/web/src/lib/browser-node-unsupported.ts b/apps/web/src/lib/browser-node-unsupported.ts new file mode 100644 index 0000000..f324814 --- /dev/null +++ b/apps/web/src/lib/browser-node-unsupported.ts @@ -0,0 +1,13 @@ +function unsupported(name: string) { + return () => { + throw new Error(`${name} is unavailable in the browser`); + }; +} + +const nodeUnsupported = new Proxy({}, { + get(_target, property) { + return unsupported(String(property)); + }, +}); + +export default nodeUnsupported; diff --git a/apps/web/src/lib/identity.ts b/apps/web/src/lib/identity.ts index a74daf6..34e8daf 100644 --- a/apps/web/src/lib/identity.ts +++ b/apps/web/src/lib/identity.ts @@ -9,6 +9,7 @@ import { } from "@trust0/identity"; const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8788"; +const ASPE_DOMAIN = import.meta.env.VITE_ASPE_DOMAIN || "trust0.app"; // ── Key Storage (IndexedDB — CryptoKey objects stored as opaque handles) ──── @@ -104,6 +105,10 @@ export interface MyProfile { updatedAt: string; } +function getAspeUri(fingerprint: string): string { + return `aspe:${ASPE_DOMAIN}:${fingerprint}`; +} + export async function fetchMyProfile(): Promise { try { const res = await fetch(`${API_BASE}/api/identity/my-profile`, { @@ -168,6 +173,7 @@ export async function updateProfile( description?: string, avatarUrl?: string, color?: string, + targetFingerprint?: string, ): Promise { const profileJws = await createProfile({ name, @@ -183,7 +189,7 @@ export async function updateProfile( const requestJws = await createRequest({ action: "update", profileJws, - aspeUri: `aspe:${identity.fingerprint}`, + aspeUri: getAspeUri(targetFingerprint ?? identity.fingerprint), key: identity.privateKey, publicJWK: identity.publicJWK, fingerprint: identity.fingerprint, @@ -349,7 +355,6 @@ export async function claimUsername( // ── Email Challenge-Response Verification ────────────────────────────────── export async function requestEmailChallenge(): Promise<{ - challenge: string; email: string; expiresAt: string; }> { @@ -363,7 +368,7 @@ export async function requestEmailChallenge(): Promise<{ throw new Error(err.error); } - return (await res.json()) as { challenge: string; email: string; expiresAt: string }; + return (await res.json()) as { email: string; expiresAt: string }; } export async function verifyEmailChallenge( @@ -527,7 +532,15 @@ export async function rotateKey( }); // 3. Create new profile with new key - await uploadProfile(newIdentity, name, claims, description); + await updateProfile( + newIdentity, + name, + claims, + description, + undefined, + undefined, + oldIdentity.fingerprint, + ); // 4. Append profile_update link signed by NEW key (now authorized via rotation) await appendAfterAction(newIdentity, identityId, "profile_update", { diff --git a/apps/web/src/routes/identity/+page.svelte b/apps/web/src/routes/identity/+page.svelte index f0f071b..97ed250 100644 --- a/apps/web/src/routes/identity/+page.svelte +++ b/apps/web/src/routes/identity/+page.svelte @@ -200,8 +200,16 @@ attestingEmail = true; error = null; try { - // Step 1: Request challenge (server sends email) - const { challenge, email } = await requestEmailChallenge(); + // Step 1: Request challenge (server sends email only) + const { email } = await requestEmailChallenge(); + + const challenge = window.prompt( + `Check ${email} for your verification challenge, then paste it here to continue.`, + )?.trim(); + + if (!challenge) { + throw new Error("Email verification cancelled"); + } // Step 2: Sign challenge with identity key and submit const { email: verifiedEmail } = await verifyEmailChallenge(identity, challenge); @@ -765,12 +773,12 @@ {#if showSshKey}
- +
SSH Public Key
{sshPublicKey}
- +
Git Config
git config --global gpg.format ssh
 git config --global user.signingkey ~/.ssh/identity_ed25519.pub
 git config --global commit.gpgsign true
@@ -855,9 +863,8 @@ git config --global commit.gpgsign true {/if}
{/if} -{/if} - \ No newline at end of file + diff --git a/apps/web/src/routes/identity/bitcoin/+page.svelte b/apps/web/src/routes/identity/bitcoin/+page.svelte index 4286cf5..69b8530 100644 --- a/apps/web/src/routes/identity/bitcoin/+page.svelte +++ b/apps/web/src/routes/identity/bitcoin/+page.svelte @@ -128,5 +128,4 @@ diff --git a/apps/web/src/routes/identity/dns/+page.svelte b/apps/web/src/routes/identity/dns/+page.svelte index 51f51bf..b5147fc 100644 --- a/apps/web/src/routes/identity/dns/+page.svelte +++ b/apps/web/src/routes/identity/dns/+page.svelte @@ -1,7 +1,6 @@