diff --git a/api/ssr.ts b/api/ssr.ts index 8c94557f6..45ff2711c 100644 --- a/api/ssr.ts +++ b/api/ssr.ts @@ -1,8 +1,9 @@ // api/ssr.ts import fs from "node:fs"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { PassThrough } from "node:stream"; +import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { PipeableStream } from "react-dom/server"; @@ -31,6 +32,8 @@ type ManifestEntry = { src?: string; }; +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + function pickRender(mod: unknown): RenderFn | null { if (!mod || typeof mod !== "object") return null; const m = mod as SsrModule; @@ -49,14 +52,15 @@ function toPublicPath(file?: string): string | null { function buildSsrHead(): string { const parts: string[] = []; - const manifestPath = path.join(process.cwd(), "dist", "client", ".vite", "manifest.json"); + const manifestPaths = [ + path.join(ROOT_DIR, "dist", ".vite", "manifest.json"), + path.join(ROOT_DIR, "dist", "client", ".vite", "manifest.json"), + ]; try { - if (fs.existsSync(manifestPath)) { - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record< - string, - ManifestEntry - >; + const manifestPath = manifestPaths.find((candidate) => fs.existsSync(candidate)); + if (manifestPath) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; const entryKey = Object.keys(manifest).find((key) => manifest[key]?.src?.includes("entry-client")) ?? Object.keys(manifest).find((key) => manifest[key]?.isEntry); @@ -127,28 +131,107 @@ function formatErrorForDebug(err: unknown): string { } } -export default async function handler(req: IncomingMessage, res: ServerResponse) { - const ABORT_DELAY_MS = 10_000; +type TemplateParts = { head: string; tail: string }; - try { - const url = absoluteUrl(req); - const DEBUG = url.searchParams.has("__ssr_debug"); +function minimalTemplate(): TemplateParts { + return { + head: + "
", + tail: "
", + }; +} - const templatePath = path.join(process.cwd(), "dist", "server", "template.html"); +function loadTemplate(): TemplateParts { + const templatePath = path.join(ROOT_DIR, "dist", "server", "template.html"); + try { const template = fs.readFileSync(templatePath, "utf8"); const templateWithHead = template.replace("", buildSsrHead()); - const { head, tail } = splitTemplate(templateWithHead); + return splitTemplate(templateWithHead); + } catch (err) { + console.error("SSR template load failed:", err); + return minimalTemplate(); + } +} + +function resolveEntryServerPath(): string | null { + const serverDir = path.join(ROOT_DIR, "dist", "server"); + const candidates = ["entry-server.js", "entry-server.mjs", "entry-server.cjs"]; + + for (const name of candidates) { + const candidate = path.join(serverDir, name); + if (fs.existsSync(candidate)) return candidate; + } + + try { + const files = fs.readdirSync(serverDir); + const match = files.find((file) => /^entry-server\.(mjs|cjs|js)$/i.test(file)); + return match ? path.join(serverDir, match) : null; + } catch { + return null; + } +} + +function respondWithShell( + res: ServerResponse, + parts: TemplateParts, + requestId: string, + opts: { debug: boolean; error?: unknown } +) { + if (!res.headersSent) { + res.statusCode = 200; + res.setHeader("content-type", "text/html; charset=utf-8"); + res.setHeader("cache-control", "no-store"); + res.setHeader("x-ssr", "0"); + res.setHeader("x-ssr-request-id", requestId); + if (opts.debug) { + res.setHeader("x-ssr-debug", "1"); + } + } - const entryPath = path.join(process.cwd(), "dist", "server", "entry-server.js"); + if (opts.debug && opts.error) { + const payload = escapeHtml(formatErrorForDebug(opts.error)); + const debugTemplate = minimalTemplate(); + const style = + ""; + res.end(`${debugTemplate.head}${style}
${payload}
${debugTemplate.tail}`); + return; + } + + res.end(parts.head + parts.tail); +} + +export default async function handler(req: IncomingMessage, res: ServerResponse) { + const ABORT_DELAY_MS = 10_000; + const requestId = randomUUID(); + + try { + const url = absoluteUrl(req); + const DEBUG = url.searchParams.has("__ssr_debug") || req.headers["x-ssr-debug"] === "1"; + const templateParts = loadTemplate(); + const { head, tail } = templateParts; + + const entryPath = resolveEntryServerPath(); + if (!entryPath) { + const err = new Error("SSR entry-server bundle not found in dist/server"); + console.error(`[SSR ${requestId}] entry not found`); + respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err }); + return; + } const entryUrl = pathToFileURL(entryPath).href; - const mod = (await import(entryUrl)) as unknown; - const render = pickRender(mod); + let render: RenderFn | null = null; + try { + const mod = (await import(entryUrl)) as unknown; + render = pickRender(mod); + } catch (err) { + console.error(`[SSR ${requestId}] entry import failed:`, err); + respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err }); + return; + } if (!render) { - res.statusCode = 500; - res.setHeader("content-type", "text/plain; charset=utf-8"); - res.end("SSR entry missing render() export"); + console.error(`[SSR ${requestId}] entry missing render() export`); + respondWithShell(res, templateParts, requestId, { debug: DEBUG }); return; } @@ -158,6 +241,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) res.setHeader("content-type", "text/html; charset=utf-8"); res.setHeader("cache-control", "no-store"); res.setHeader("x-ssr", "1"); + res.setHeader("x-ssr-request-id", requestId); let shellFlushed = false; let pipeable: PipeableStream | null = null; @@ -213,10 +297,8 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) // If shell failed before onShellReady, we still want to return an HTML document. // Debug mode: show error details in the browser. if (!shellFlushed) { - if (DEBUG) { - if (!res.writableEnded) res.end(`
${escapeHtml(formatErrorForDebug(err))}
`); - } else { - if (!res.writableEnded) res.end(head + tail); + if (!res.writableEnded) { + respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err }); } return; } @@ -233,12 +315,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) }; // Start React SSR stream - pipeable = render(url.pathname + url.search, null, opts); + try { + pipeable = render(url.pathname + url.search, null, opts); + } catch (err) { + console.error(`[SSR ${requestId}] render crashed:`, err); + respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err }); + } } catch (err) { - console.error("SSR function crashed:", err); - res.statusCode = 500; - res.setHeader("content-type", "text/plain; charset=utf-8"); - res.end("SSR function crashed"); + console.error(`[SSR ${requestId}] function crashed:`, err); + const DEBUG = req.headers["x-ssr-debug"] === "1"; + respondWithShell(res, loadTemplate(), requestId, { debug: DEBUG, error: err }); } } diff --git a/package.json b/package.json index d47b2a0e2..6db883ae9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev:ssr": "NODE_ENV=development node server.mjs", "build:clean": "rm -rf dist .vite && pnpm run build:ssr", "build": "tsc -b && vite build", - "build:ssr": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && vite build --ssr src/entry-server-exports.ts --outDir dist/server && node scripts/ssr-pack.mjs", + "build:ssr": "rm -rf dist/server && tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && vite build --ssr src/entry-server-exports.ts --outDir dist/server && node scripts/ssr-pack.mjs", "lint": "eslint .", "preview": "NODE_ENV=production node server.mjs", "test": "node --test" diff --git a/public/sw.js b/public/sw.js index f315492e8..ec111c681 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.0.0"; // update on release +const APP_VERSION = "42.2.0"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; @@ -168,6 +168,13 @@ async function warmUrls(urls, { mapShell = false } = {}) { const url = normalizeWarmUrl(raw); if (!url) return; + const hasExtension = url.pathname.split("/").pop()?.includes("."); + + if (mapShell && shell && !hasExtension) { + await mapShellToRoute(url.href, shell); + return; + } + const cacheName = cacheBucketFor(url); const req = new Request(url.href, { cache: "reload" }); diff --git a/src/App.tsx b/src/App.tsx index 2226afd13..247fa2e4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1219,7 +1219,7 @@ export function AppChrome(): React.JSX.Element { }); await Promise.all( - [...OFFLINE_ASSETS_TO_WARM, ...SHELL_ROUTES_TO_WARM].map(async (url) => { + [...OFFLINE_ASSETS_TO_WARM, ...APP_SHELL_HINTS].map(async (url) => { try { await fetch(url, { cache: "no-cache", signal: aborter.signal }); } catch { diff --git a/src/og/buildVerifiedCardSvg.ts b/src/og/buildVerifiedCardSvg.ts index b408910d7..91cdfdd95 100644 --- a/src/og/buildVerifiedCardSvg.ts +++ b/src/og/buildVerifiedCardSvg.ts @@ -2,9 +2,11 @@ import phiSvg from "../assets/phi.svg?raw"; import type { VerifiedCardData } from "./types"; import { sanitizeSigilSvg, svgToDataUri } from "./sigilEmbed"; import { currency as fmtPhi, usd as fmtUsd } from "../components/valuation/display"; +import { buildProofOfBreathSeal } from "./proofOfBreathSeal"; -const WIDTH = 1200; -const HEIGHT = 630; +export const VERIFIED_CARD_W = 1200; +export const VERIFIED_CARD_H = 630; +const PHI = (1 + Math.sqrt(5)) / 2; const phiLogoDataUri = svgToDataUri(phiSvg); function hashStringToInt(value: string): number { @@ -32,13 +34,44 @@ function shortPhiKey(phiKey: string): string { function badgeMark(ok: boolean): string { if (ok) { - return "M20 34 L28 42 L44 20"; + return "M18 32 L28 42 L46 18"; } return "M20 20 L44 44 M44 20 L20 44"; } -function headerCheckPath(): string { - return "M16 26 L26 36 L44 16"; +type TextMetricsLite = { width: number; ascent: number; descent: number }; + +function measureTextMetrics( + text: string, + font: string, + fontPx: number, + letterSpacingEm = 0, +): TextMetricsLite { + let width = text.length * fontPx * 0.6; + let ascent = fontPx * 0.8; + let descent = fontPx * 0.2; + + let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; + if (typeof OffscreenCanvas !== "undefined") { + ctx = new OffscreenCanvas(1, 1).getContext("2d"); + } else if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + ctx = canvas.getContext("2d"); + } + + if (ctx) { + ctx.font = font; + const metrics = ctx.measureText(text); + width = metrics.width; + ascent = metrics.actualBoundingBoxAscent || ascent; + descent = metrics.actualBoundingBoxDescent || descent; + } + + if (letterSpacingEm > 0 && text.length > 1) { + width += (text.length - 1) * letterSpacingEm * fontPx; + } + + return { width, ascent, descent }; } function dropUndefined>(value: T): T { @@ -59,16 +92,16 @@ function formatUsdValue(value: number | null | undefined): string { function sigilImageMarkup(sigilSvg: string | undefined, clipId: string): string { if (!sigilSvg) { return ` - - Sigil unavailable + + Sigil unavailable `; } const sanitized = sanitizeSigilSvg(sigilSvg); const dataUri = svgToDataUri(sanitized); return ` + QR unavailable + `; + } + return ` + + `; +} + +function shortHash(value: string | undefined, head = 10, tail = 8): string { + if (!value) return "—"; + if (value.length <= head + tail + 2) return value; + return `${value.slice(0, head)}…${value.slice(-tail)}`; +} + +function sealPlacement(seedValue: string): { x: number; y: number; rotation: number; dash: string } { + const hash = hashStringToInt(seedValue); + const offsetX = (hash % 48) - 24; + const offsetY = ((hash >> 6) % 24) - 12; + const rotation = ((hash % 41) - 20) * 0.5; + const dashA = 4 + (hash % 5); + const dashB = 3 + ((hash >> 3) % 4); + return { x: 1008 + offsetX, y: 92 + offsetY, rotation, dash: `${dashA} ${dashB}` }; +} + +function sealBrandIcon(x: number, y: number, palette: { primary: string; accent: string }): string { + return ` + + + + + `; +} + +function ornamentMarkup( + x: number, + y: number, + width: number, + height: number, + palette: { primary: string; secondary: string; accent: string }, + geometry: { tickCount: number; polygonSides: number }, +): string { + const inset = 16; + const left = x + inset; + const right = x + width - inset; + const top = y + inset; + const bottom = y + height - inset; + const cx = x + width - inset - 24; + const cy = y + inset + 24; + const ticks = Array.from({ length: Math.min(geometry.tickCount, 18) }).map((_, idx) => { + const t = (idx / Math.min(geometry.tickCount, 18)) * Math.PI * 0.6 + Math.PI * 1.2; + const inner = 18; + const outer = 26; + const x1 = cx + Math.cos(t) * inner; + const y1 = cy + Math.sin(t) * inner; + const x2 = cx + Math.cos(t) * outer; + const y2 = cy + Math.sin(t) * outer; + return ``; + }); + const poly = Array.from({ length: geometry.polygonSides }).map((_, idx) => { + const t = (idx / geometry.polygonSides) * Math.PI * 2 - Math.PI / 2; + const px = cx + Math.cos(t) * 12; + const py = cy + Math.sin(t) * 12; + return `${idx === 0 ? "M" : "L"}${px.toFixed(2)} ${py.toFixed(2)}`; + }); + return ` + + + + + ${ticks.join("\n")} + + + `; +} + export function buildVerifiedCardSvg(data: VerifiedCardData): string { - const { capsuleHash, verifiedAtPulse, phikey, kasOk, g16Ok, sigilSvg } = data; + const { capsuleHash, verifiedAtPulse, phikey, kasOk, g16Ok, sigilSvg, qrDataUrl, svgHash, receiptHash } = data; const { accent, accentSoft, accentGlow } = accentFromHash(capsuleHash); const id = `og-${hashStringToInt(capsuleHash).toString(16)}`; const sigilClipId = `${id}-sigil-clip`; + const qrClipId = `${id}-qr-clip`; const ringGradientId = `${id}-ring`; const glowId = `${id}-glow`; const waveId = `${id}-wave`; const badgeGlowId = `${id}-badge-glow`; + const sealId = `${id}-seal`; + const hasKas = typeof kasOk === "boolean"; + const sealSeed = `${capsuleHash}|${svgHash ?? ""}|${verifiedAtPulse}`; + const seal = sealPlacement(sealSeed); const phiShort = shortPhiKey(phikey); - const valuationSnapshot = data.valuation ? { ...data.valuation } : undefined; + const valuationSnapshot = data.valuation ? { ...data.valuation } : data.receipt?.valuation ? { ...data.receipt.valuation } : undefined; if (valuationSnapshot && "valuationHash" in valuationSnapshot) { delete (valuationSnapshot as { valuationHash?: string }).valuationHash; } const valuationHash = data.valuation?.valuationHash ?? data.receipt?.valuationHash; const valuationPhi = formatPhiValue(valuationSnapshot?.phiValue); const valuationUsd = formatUsdValue(valuationSnapshot?.usdValue); - const valuationModeLabel = - valuationSnapshot?.mode === "receive" ? "RECEIVE" : valuationSnapshot?.mode === "origin" ? "ORIGIN" : null; + const isReceiveMode = valuationSnapshot?.mode === "receive"; + const valuationModeLabel = isReceiveMode ? "RECEIVE" : "ORIGIN"; + const headlineText = isReceiveMode ? "VERIFIED RECEIVE" : "VERIFIED ORIGIN"; const receiptPayload = data.receipt ?? @@ -110,11 +234,11 @@ export function buildVerifiedCardSvg(data: VerifiedCardData): string { } : undefined); const receiptMeta: Record = {}; - const bundleHash = receiptPayload?.bundleHash ?? data.bundleHash; + const bundleHashValue = receiptPayload?.bundleHash ?? data.bundleHash; const zkPoseidonHash = receiptPayload?.zkPoseidonHash ?? data.zkPoseidonHash; const verifier = receiptPayload?.verifier ?? data.verifier; const verificationVersion = receiptPayload?.verificationVersion ?? data.verificationVersion; - if (bundleHash) receiptMeta.bundleHash = bundleHash; + if (bundleHashValue) receiptMeta.bundleHash = bundleHashValue; if (zkPoseidonHash) receiptMeta.zkPoseidonHash = zkPoseidonHash; if (verifier) receiptMeta.verifier = verifier; if (verificationVersion) receiptMeta.verificationVersion = verificationVersion; @@ -124,29 +248,109 @@ export function buildVerifiedCardSvg(data: VerifiedCardData): string { if (data.verificationSig) receiptMeta.verificationSig = data.verificationSig; const receiptJson = JSON.stringify(receiptMeta); + const proofSeal = buildProofOfBreathSeal({ + bundleHash: bundleHashValue, + capsuleHash, + svgHash, + receiptHash, + pulse: verifiedAtPulse, + }); + const auditMeta = dropUndefined({ receiptHash: data.receiptHash, valuation: valuationSnapshot, valuationHash, - bundleHash, + bundleHash: bundleHashValue, zkPoseidonHash, verifiedAtPulse: receiptPayload?.verifiedAtPulse ?? verifiedAtPulse, }); const auditJson = JSON.stringify(auditMeta); + const proofSealLabel = "PROOF OF BREATH™"; + const proofSealMicro = `${shortHash(capsuleHash, 6, 4)} · ${shortHash(bundleHashValue, 6, 4)}`; + const proofSealSizeDefault = 240; + const proofSealRadiusDefault = Math.floor(proofSealSizeDefault / 2); + const proofSealRadiusMin = 120; + const proofSealXDefault = 600; + const proofSealY = 312; + const sigilFrameX = 796; + const sigilFrameY = 136; + const sigilFrameSize = 348; + const unit = 22; + const phiGap = Math.round(unit * PHI); + const titleY = 134; + const subheadY = titleY + 42; + const modeLabelY = subheadY + 34; + const badgeLabelY = modeLabelY + Math.round(unit * 2.15); + const valueLabelY = badgeLabelY + phiGap; + const valueY = valueLabelY + Math.round(unit * 1.4); + const usdLabelY = valueY + phiGap; + const usdValueY = usdLabelY + Math.round(unit * 1.4); + const qrBoxX = 128; + const qrBoxY = usdValueY + Math.round(phiGap * 0.45); + const qrBoxW = 288; + const qrBoxH = 140; + const brandX = VERIFIED_CARD_W / 2; + const brandY = 78; + const brandText = "SIGIL-SEAL"; + const brandFontPx = 14; + const brandLetterSpacingEm = 0.42; + const brandFont = `600 ${brandFontPx}px Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif`; + const brandMetrics = measureTextMetrics(brandText, brandFont, brandFontPx, brandLetterSpacingEm); + const brandIconSize = 12; + const brandIconGap = Math.round(brandFontPx * 0.6); + const brandGroupWidth = brandMetrics.width + brandIconSize + brandIconGap; + const brandTextX = brandX - brandGroupWidth / 2 + brandIconSize + brandIconGap; + const brandIconX = brandX - brandGroupWidth / 2 + brandIconSize / 2; + const labelFontPx = 22; + const labelLetterSpacingEm = 0.08; + const labelFont = `700 ${labelFontPx}px Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif`; + const g16LabelX = hasKas ? 470 : 320; + const g16Metrics = measureTextMetrics("G16", labelFont, labelFontPx, labelLetterSpacingEm); + const g16BadgeSize = Math.round(labelFontPx * 1.25); + const g16BadgeGap = Math.round(labelFontPx * 0.45); + const g16BadgeX = g16LabelX + g16Metrics.width + g16BadgeGap; + const g16BadgeY = badgeLabelY - Math.round(Math.max(g16Metrics.ascent * 0.9, g16BadgeSize * 0.85)); + const g16BadgeRadius = Math.round(g16BadgeSize * 0.26); + const g16BadgeStroke = Math.max(1.4, g16BadgeSize * 0.033); + const g16MarkScale = (g16BadgeSize / 54) * 0.5; + const g16MarkTranslate = (g16BadgeSize / 54) * 13.5; + const valueFontPx = 30; + const valueFont = `700 ${valueFontPx}px Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif`; + const valueMetrics = measureTextMetrics(valuationPhi, valueFont, valueFontPx); + const usdMetrics = measureTextMetrics(valuationUsd, valueFont, valueFontPx); + const leftValueRightEdge = 320 + Math.max(valueMetrics.width, usdMetrics.width); + const safeGap = 24; + const badgeLeftBound = leftValueRightEdge + safeGap; + let proofSealX = proofSealXDefault; + let allowedRadius = proofSealX - badgeLeftBound; + let proofSealRadius = Math.min(proofSealRadiusDefault, allowedRadius); + if (proofSealRadius < proofSealRadiusMin) { + const neededShift = Math.min(24, proofSealRadiusMin - proofSealRadius); + proofSealX += neededShift; + allowedRadius = proofSealX - badgeLeftBound; + proofSealRadius = Math.min(proofSealRadiusDefault, allowedRadius); + } + proofSealRadius = Math.max(0, proofSealRadius); + const proofSealSize = proofSealRadius * 2; + const proofSealMarkup = proofSeal.toSvg(proofSealX, proofSealY, proofSealSize, id, proofSealLabel, proofSealMicro); + const phiKeyLabelY = Math.round(proofSealY + proofSealSize / 2 + phiGap * 0.7); + const phiKeyValueY = phiKeyLabelY + Math.round(unit * 1.9); + const brandPalette = proofSeal.palette; + const ornament = ornamentMarkup(810, 150, 320, 320, brandPalette, proofSeal.geometry); return ` - + ${receiptJson} ${auditJson} - - - - + + + + - + @@ -168,79 +372,109 @@ export function buildVerifiedCardSvg(data: VerifiedCardData): string { - - + + + + + - + + + - + + - - - - - - + + + + + + - - + + - VERIFIED - ${valuationModeLabel ? `${valuationModeLabel}` : ""} - - - - + ${sealBrandIcon(brandIconX, brandY - 4, { primary: brandPalette.primary, accent: brandPalette.accent })} + ${brandText} - - Steward Verified @ Pulse ${verifiedAtPulse} • ΦKey - - ${phiShort} + ${headlineText} + Steward Verified @ Pulse ${verifiedAtPulse} + ${valuationModeLabel ? `${valuationModeLabel}` : ""} - KAS - - - - + ΦKEY + ${phiShort} + + ${hasKas ? `KAS` : ""} + ${ + hasKas + ? ` + + + + + ` + : "" + } - G16 - - - + G16 + + + + + - Φ VALUE (MINTED) - ${valuationPhi} + Φ VALUE + ${valuationPhi} - USD VALUE (MINTED) - ${valuationUsd} + USD VALUE + ${valuationUsd} - - + + + + VERIFIED + ${shortHash(capsuleHash, 6, 4)} + + + ${proofSealMarkup} + + + ${sigilImageMarkup(sigilSvg, sigilClipId)} + ${ornament} - - Proof of Breath™ — VERIFIED - phi.network + + ${qrImageMarkup(qrDataUrl, qrClipId, qrBoxX + 12, qrBoxY + 8)} + + + BUNDLE ${shortHash(bundleHashValue)} + RECEIPT ${shortHash(receiptHash)} + SVG ${shortHash(svgHash)} + CAPSULE ${shortHash(capsuleHash)} + phi.network + `.trim(); } diff --git a/src/og/downloadVerifiedCard.ts b/src/og/downloadVerifiedCard.ts index 9731063d5..750b416a9 100644 --- a/src/og/downloadVerifiedCard.ts +++ b/src/og/downloadVerifiedCard.ts @@ -1,6 +1,9 @@ import { downloadBlob } from "../lib/download"; +import { qrDataURL } from "../lib/qr"; +import { POSTER_PX } from "../utils/qrExport"; +import { insertPngTextChunks } from "../utils/pngChunks"; import type { VerifiedCardData } from "./types"; -import { buildVerifiedCardSvg } from "./buildVerifiedCardSvg"; +import { buildVerifiedCardSvg, VERIFIED_CARD_H, VERIFIED_CARD_W } from "./buildVerifiedCardSvg"; import { svgToPngBlob } from "./svgToPng"; function fileNameForCapsule(hash: string, verifiedAtPulse: number): string { @@ -8,24 +11,51 @@ function fileNameForCapsule(hash: string, verifiedAtPulse: number): string { return `verified-${safe}-${verifiedAtPulse}.png`; } +function buildPointerPayload(data: VerifiedCardData): string { + const payload = { + v: "KVB-PTR-1", + verifierUrl: data.verifierUrl ?? "", + bundleHash: data.bundleHash ?? "", + receiptHash: data.receiptHash ?? "", + verifiedAtPulse: data.verifiedAtPulse, + capsuleHash: data.capsuleHash, + svgHash: data.svgHash ?? "", + } as const; + return JSON.stringify(payload); +} + +function exportWidthPx(): number { + return POSTER_PX; +} + +function exportHeightPx(): number { + const width = exportWidthPx(); + const sourceRatio = VERIFIED_CARD_H / VERIFIED_CARD_W; + return Math.round(width * sourceRatio); +} + export async function downloadVerifiedCardPng(data: VerifiedCardData): Promise { const filename = fileNameForCapsule(data.capsuleHash, data.verifiedAtPulse); - const ogUrl = `/og/v/verified/${encodeURIComponent(data.capsuleHash)}/${encodeURIComponent(String(data.verifiedAtPulse))}.png`; - - try { - const res = await fetch(ogUrl, { method: "GET" }); - const contentType = res.headers.get("content-type") || ""; - const notFoundHeader = res.headers.get("x-og-not-found"); - if (res.ok && !notFoundHeader && contentType.toLowerCase().startsWith("image/png")) { - const blob = await res.blob(); - downloadBlob(blob, filename); - return; - } - } catch { - // Fall back to client render + const qrPayload = buildPointerPayload(data); + const qrDataUrl = await qrDataURL(qrPayload, { size: 360, margin: 1, ecc: "M" }); + + const svg = buildVerifiedCardSvg({ ...data, qrDataUrl }); + const pngBlob = await svgToPngBlob(svg, exportWidthPx(), exportHeightPx()); + + const proofBundleJson = data.proofBundleJson ?? ""; + const entries = [ + proofBundleJson ? { keyword: "phi_proof_bundle", text: proofBundleJson } : null, + data.bundleHash ? { keyword: "phi_bundle_hash", text: data.bundleHash } : null, + data.receiptHash ? { keyword: "phi_receipt_hash", text: data.receiptHash } : null, + ].filter((entry): entry is { keyword: string; text: string } => Boolean(entry)); + + if (entries.length === 0) { + downloadBlob(pngBlob, filename); + return; } - const svg = buildVerifiedCardSvg(data); - const pngBlob = await svgToPngBlob(svg, 1200, 630); - downloadBlob(pngBlob, filename); + const bytes = new Uint8Array(await pngBlob.arrayBuffer()); + const enriched = insertPngTextChunks(bytes, entries); + const finalBlob = new Blob([enriched as BlobPart], { type: "image/png" }); + downloadBlob(finalBlob, filename); } diff --git a/src/og/proofOfBreathSeal.ts b/src/og/proofOfBreathSeal.ts new file mode 100644 index 000000000..9eb90bbc6 --- /dev/null +++ b/src/og/proofOfBreathSeal.ts @@ -0,0 +1,287 @@ +export type HashHex = string; + +type ProofSealInput = { + bundleHash: HashHex | undefined; + capsuleHash: HashHex; + svgHash?: HashHex; + receiptHash?: HashHex; + pulse?: number; +}; + +type SealPalette = { + primary: string; + secondary: string; + accent: string; + glow: string; +}; + +type SealGeometry = { + ringCount: number; + tickCount: number; + polygonSides: number; + rosetteCount: number; + rosetteA: number; + rosetteB: number; + rosetteC: number; + dashPattern: string; + glowAlpha: number; + gradientAngle: number; + glassOpacity: number; +}; + +export type ProofOfBreathSeal = { + palette: SealPalette; + geometry: SealGeometry; + draw: (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => void; + toSvg: (x: number, y: number, size: number, idPrefix: string, label: string, microtext: string) => string; +}; + +function hexToBytes(hex: string): Uint8Array { + const cleaned = hex.toLowerCase().replace(/[^0-9a-f]/g, ""); + const normalized = cleaned.length % 2 === 1 ? `0${cleaned}` : cleaned; + const bytes = new Uint8Array(Math.floor(normalized.length / 2)); + for (let i = 0; i < bytes.length; i += 1) { + const idx = i * 2; + bytes[i] = parseInt(normalized.slice(idx, idx + 2), 16); + } + return bytes; +} + +function seedFromBytes(bytes: Uint8Array): number { + let hash = 2166136261; + for (const byte of bytes) { + hash ^= byte; + hash = Math.imul(hash, 16777619) >>> 0; + } + return hash >>> 0; +} + +function mulberry32(seed: number): () => number { + let t = seed >>> 0; + return () => { + t = (t + 0x6d2b79f5) >>> 0; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; +} + +function randInt(rand: () => number, min: number, max: number): number { + return Math.floor(rand() * (max - min + 1)) + min; +} + +function hsl(hue: number, sat: number, light: number): string { + return `hsl(${hue} ${sat}% ${light}%)`; +} + +function buildRosettePath(radius: number, a: number, b: number, c: number, steps = 220): string { + const pts: string[] = []; + for (let i = 0; i <= steps; i += 1) { + const t = (i / steps) * Math.PI * 2; + const r = radius * (0.62 + 0.28 * Math.sin(c * t)); + const x = Math.cos(a * t) * r; + const y = Math.sin(b * t) * r; + pts.push(`${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`); + } + return `${pts.join(" ")} Z`; +} + +function polygonPath(radius: number, sides: number, rotation = -Math.PI / 2): string { + const pts: string[] = []; + for (let i = 0; i < sides; i += 1) { + const t = rotation + (i / sides) * Math.PI * 2; + const x = Math.cos(t) * radius; + const y = Math.sin(t) * radius; + pts.push(`${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`); + } + return `${pts.join(" ")} Z`; +} + +export function buildProofOfBreathSeal(input: ProofSealInput): ProofOfBreathSeal { + const seedBytes = hexToBytes( + `${input.capsuleHash}${(input.bundleHash ?? "").slice(0, 32)}${input.svgHash ?? ""}${input.receiptHash ?? ""}`, + ); + const seed = seedFromBytes(seedBytes); + const rand = mulberry32(seed); + const pulseShift = typeof input.pulse === "number" && Number.isFinite(input.pulse) ? Math.abs(input.pulse) % 360 : 0; + const baseHue = randInt(rand, 0, 359); + const accentHue = (baseHue + randInt(rand, 24, 160)) % 360; + const secondaryHue = (baseHue + randInt(rand, 180, 260)) % 360; + const palette: SealPalette = { + primary: hsl(baseHue, 78, 62), + secondary: hsl(secondaryHue, 62, 58), + accent: hsl(accentHue, 76, 64), + glow: `hsla(${baseHue}, 82%, 70%, ${0.35 + rand() * 0.25})`, + }; + + const geometry: SealGeometry = { + ringCount: randInt(rand, 2, 4), + tickCount: randInt(rand, 12, 24), + polygonSides: randInt(rand, 5, 11), + rosetteCount: randInt(rand, 2, 3), + rosetteA: randInt(rand, 2, 6), + rosetteB: randInt(rand, 3, 7), + rosetteC: randInt(rand, 2, 5), + dashPattern: `${randInt(rand, 4, 8)} ${randInt(rand, 2, 6)}`, + glowAlpha: 0.28 + rand() * 0.3, + gradientAngle: (baseHue + accentHue + pulseShift) % 360, + glassOpacity: 0.42 + rand() * 0.2, + }; + + const draw = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { + const radius = size / 2; + const angle = (geometry.gradientAngle * Math.PI) / 180; + const gradX = Math.cos(angle) * radius; + const gradY = Math.sin(angle) * radius; + const strokeGradient = ctx.createLinearGradient(-gradX, -gradY, gradX, gradY); + strokeGradient.addColorStop(0, palette.primary); + strokeGradient.addColorStop(0.55, palette.accent); + strokeGradient.addColorStop(1, palette.secondary); + const glassGradient = ctx.createRadialGradient(0, 0, radius * 0.1, 0, 0, radius); + glassGradient.addColorStop(0, palette.accent); + glassGradient.addColorStop(0.5, palette.primary); + glassGradient.addColorStop(1, "rgba(10,12,18,0.7)"); + ctx.save(); + ctx.translate(x, y); + ctx.lineCap = "round"; + for (let i = 0; i < geometry.ringCount; i += 1) { + const ringR = radius * (0.92 - i * 0.12); + ctx.strokeStyle = i % 2 === 0 ? strokeGradient : palette.secondary; + ctx.lineWidth = 1.6 - i * 0.2; + ctx.beginPath(); + ctx.setLineDash(i === 0 ? geometry.dashPattern.split(" ").map(Number) : []); + ctx.arc(0, 0, ringR, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.setLineDash([]); + ctx.strokeStyle = palette.accent; + ctx.lineWidth = 1.1; + for (let i = 0; i < geometry.tickCount; i += 1) { + const t = (i / geometry.tickCount) * Math.PI * 2; + const inner = radius * 0.82; + const outer = radius * 0.95; + ctx.beginPath(); + ctx.moveTo(Math.cos(t) * inner, Math.sin(t) * inner); + ctx.lineTo(Math.cos(t) * outer, Math.sin(t) * outer); + ctx.stroke(); + } + for (let i = 0; i < geometry.rosetteCount; i += 1) { + const scale = radius * (0.52 - i * 0.08); + const a = geometry.rosetteA + i; + const b = geometry.rosetteB + i; + const c = geometry.rosetteC + i; + ctx.strokeStyle = i % 2 === 0 ? palette.secondary : palette.accent; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let s = 0; s <= 240; s += 1) { + const t = (s / 240) * Math.PI * 2; + const r = scale * (0.62 + 0.28 * Math.sin(c * t)); + const px = Math.cos(a * t) * r; + const py = Math.sin(b * t) * r; + if (s === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.stroke(); + } + ctx.fillStyle = glassGradient; + ctx.strokeStyle = strokeGradient; + ctx.lineWidth = 1.4; + ctx.beginPath(); + const poly = geometry.polygonSides; + for (let i = 0; i <= poly; i += 1) { + const t = (-Math.PI / 2) + (i / poly) * Math.PI * 2; + const px = Math.cos(t) * radius * 0.32; + const py = Math.sin(t) * radius * 0.32; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; + + const toSvg = (x: number, y: number, size: number, idPrefix: string, label: string, microtext: string) => { + const radius = size / 2; + const ringR = radius * 0.92; + const textR = radius * 0.72; + const gradId = `${idPrefix}-pob-grad`; + const glassId = `${idPrefix}-pob-glass`; + const frostId = `${idPrefix}-pob-frost`; + const ringPaths = Array.from({ length: geometry.ringCount }).map((_, idx) => { + const r = ringR - idx * radius * 0.12; + const stroke = idx % 2 === 0 ? `url(#${gradId})` : palette.secondary; + const dash = idx === 0 ? `stroke-dasharray="${geometry.dashPattern}"` : ""; + const width = (1.8 - idx * 0.22).toFixed(2); + return ``; + }); + + const ticks = Array.from({ length: geometry.tickCount }).map((_, idx) => { + const t = (idx / geometry.tickCount) * Math.PI * 2; + const inner = radius * 0.78; + const outer = radius * 0.95; + const x1 = Math.cos(t) * inner; + const y1 = Math.sin(t) * inner; + const x2 = Math.cos(t) * outer; + const y2 = Math.sin(t) * outer; + return ``; + }); + + const rosettes = Array.from({ length: geometry.rosetteCount }).map((_, idx) => { + const scale = radius * (0.52 - idx * 0.08); + const a = geometry.rosetteA + idx; + const b = geometry.rosetteB + idx; + const c = geometry.rosetteC + idx; + const stroke = idx % 2 === 0 ? palette.secondary : palette.accent; + const path = buildRosettePath(scale, a, b, c); + return ``; + }); + + const polyPath = polygonPath(radius * 0.3, geometry.polygonSides); + const textPathId = `${idPrefix}-pob-text`; + const microPathId = `${idPrefix}-pob-micro`; + const topArc = `M ${(-textR).toFixed(2)} 0 A ${textR.toFixed(2)} ${textR.toFixed(2)} 0 0 1 ${textR.toFixed(2)} 0`; + const bottomArc = `M ${textR.toFixed(2)} 0 A ${textR.toFixed(2)} ${textR.toFixed(2)} 0 0 1 ${(-textR).toFixed(2)} 0`; + + return ` + + + + + + + + + + + + + + + + + + + + + + + + + ${ringPaths.join("\n")} + ${ticks.join("\n")} + ${rosettes.join("\n")} + + + ${label} + + + ${microtext} + + + `; + }; + + return { palette, geometry, draw, toSvg }; +} diff --git a/src/og/types.ts b/src/og/types.ts index 11b4fa697..662f2b367 100644 --- a/src/og/types.ts +++ b/src/og/types.ts @@ -5,12 +5,14 @@ export type VerifiedCardValuation = ValuationSnapshot & { valuationHash?: string export type VerifiedCardData = { capsuleHash: string; + svgHash?: string; pulse: number; verifiedAtPulse: number; phikey: string; - kasOk: boolean; + kasOk?: boolean; g16Ok: boolean; verifierSlug?: string; + verifierUrl?: string; verifier?: string; verificationVersion?: string; bundleHash?: string; @@ -20,4 +22,6 @@ export type VerifiedCardData = { verificationSig?: VerificationSig; sigilSvg?: string; valuation?: VerifiedCardValuation; + proofBundleJson?: string; + qrDataUrl?: string; }; diff --git a/src/pages/VerifyPage.css b/src/pages/VerifyPage.css index efb7d6619..e1921f5b8 100644 --- a/src/pages/VerifyPage.css +++ b/src/pages/VerifyPage.css @@ -631,6 +631,10 @@ html.verify-shell, body.verify-shell{ color: inherit; text-align: left; } +.vdrop--receipt{ + border-color: rgba(136,190,255,0.28); + background: linear-gradient(180deg, rgba(118,170,255,0.18), rgba(118,170,255,0.08)); +} .vdrop:focus-visible{ outline: none; box-shadow: @@ -639,6 +643,19 @@ html.verify-shell, body.verify-shell{ } .vdrop-ic{ display:grid; place-items:center; } +.vdrop-ink{ + display:inline-flex; + align-items:center; + justify-content:center; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.2); + background: rgba(0,0,0,0.18); + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.12em; +} .vphi-ic{ width: 16px; height: 16px; display:block; opacity: 0.95; } .vdrop-txt{ diff --git a/src/pages/VerifyPage.tsx b/src/pages/VerifyPage.tsx index ec351caaa..d4646e2a3 100644 --- a/src/pages/VerifyPage.tsx +++ b/src/pages/VerifyPage.tsx @@ -32,23 +32,30 @@ import { ZK_STATEMENT_DOMAIN, type VerificationSource, type ProofCapsuleV1, + type NormalizedBundle, type ProofBundleLike, } from "../components/KaiVoh/verifierProof"; import { extractProofBundleMetaFromSvg, type ProofBundleMeta } from "../utils/sigilMetadata"; import { derivePhiKeyFromSig } from "../components/VerifierStamper/sigilUtils"; import { tryVerifyGroth16 } from "../components/VerifierStamper/zk"; import { isKASAuthorSig, type KASAuthorSig } from "../utils/authorSig"; -import { isWebAuthnAvailable, signBundleHash, verifyBundleAuthorSig } from "../utils/webauthnKAS"; +import { + derivePhiKeyFromPubKeyJwk, + isWebAuthnAvailable, + signBundleHash, + storePasskey, + verifyBundleAuthorSig, +} from "../utils/webauthnKAS"; import { buildKasChallenge, - ensureReceiverPasskey, - getWebAuthnAssertionJson, isReceiveSig, verifyWebAuthnAssertion, type ReceiveSig, } from "../utils/webauthnReceive"; -import { buildOwnerKeyDerivation, deriveOwnerPhiKeyFromReceive, type OwnerKeyDerivation } from "../utils/ownerPhiKey"; +import { assertionToJson, verifyOwnerWebAuthnAssertion } from "../utils/webauthnOwner"; +import { deriveOwnerPhiKeyFromReceive, type OwnerKeyDerivation } from "../utils/ownerPhiKey"; import { base64UrlDecode, base64UrlEncode, sha256Hex } from "../utils/sha256"; +import { readPngTextChunk } from "../utils/pngChunks"; import { getKaiPulseEternalInt } from "../SovereignSolar"; import { useKaiTicker } from "../hooks/useKaiTicker"; import { useValuation } from "./SigilPage/useValuation"; @@ -118,6 +125,39 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buf).set(bytes); + return buf; +} + +function normalizeRawDeclaredPhiKey(raw: string | null | undefined): string | null { + if (!raw) return null; + const value = raw.trim(); + if (!value) return null; + if (value.startsWith("φK-") || value.startsWith("ΦK-") || value.startsWith("phiK-")) return null; + return value; +} + +function hasRequiredKasAuthorSig(authorSig: unknown): authorSig is KASAuthorSig { + if (!authorSig) return false; + if (!isKASAuthorSig(authorSig)) return false; + const credId = authorSig.credId || (authorSig as { rawId?: string }).rawId; + return Boolean(credId && authorSig.pubKeyJwk); +} + + +function isChildGlyph(raw: unknown): boolean { + if (!isRecord(raw)) return false; + return Boolean( + raw.childOfHash || + raw.childClaim || + raw.childAllocationPhi || + raw.branchBasePhi || + raw.childIssuedPulse + ); +} + type DebitLoose = { amount?: number; }; @@ -126,17 +166,6 @@ type EmbeddedPhiSource = "balance" | "embedded" | "live"; type AttestationState = boolean | "missing"; -type ReceiveBundleState = { - mode?: "origin" | "receive"; - originBundleHash?: string; - receiveBundleHash?: string; - originAuthorSig?: KASAuthorSig | null; - receiveSig?: ReceiveSig | null; - receivePulse?: number; - ownerPhiKey?: string; - ownerKeyDerivation?: OwnerKeyDerivation; -}; - function readLedgerBalance(raw: unknown): { originalAmount: number; remaining: number } | null { if (!isRecord(raw)) return null; const originalAmount = typeof raw.originalAmount === "number" && Number.isFinite(raw.originalAmount) ? raw.originalAmount : null; @@ -246,6 +275,11 @@ type SharedReceipt = { verifiedAtPulse?: number; }; +type AuditBundlePayload = NormalizedBundle & { + verifiedAtPulse?: number; + zkVerified?: boolean; +}; + function parseProofCapsule(raw: unknown): ProofCapsuleV1 | null { if (!isRecord(raw)) return null; if (raw.v !== "KPV-1") return null; @@ -353,6 +387,19 @@ async function readFileText(file: File): Promise { }); } +async function readFileArrayBuffer(file: File): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Failed to read file.")); + reader.onload = () => { + const result = reader.result; + if (result instanceof ArrayBuffer) resolve(result); + else reject(new Error("Failed to read file.")); + }; + reader.readAsArrayBuffer(file); + }); +} + function ellipsizeMiddle(s: string, head = 18, tail = 14): string { const t = (s || "").trim(); if (!t) return "—"; @@ -388,6 +435,12 @@ function isSvgFile(file: File): boolean { return name.endsWith(".svg") || type === "image/svg+xml"; } +function isPngFile(file: File): boolean { + const name = (file.name || "").toLowerCase(); + const type = (file.type || "").toLowerCase(); + return name.endsWith(".png") || type === "image/png"; +} + async function copyTextToClipboard(text: string): Promise { const value = text.trim(); if (!value) return false; @@ -647,6 +700,8 @@ function Modal(props: { open: boolean; title: string; subtitle?: string; onClose export default function VerifyPage(): ReactElement { const fileRef = useRef(null); + const pngFileRef = useRef(null); + const lastAutoScanKeyRef = useRef(null); const slugRaw = useMemo(() => readSlugFromLocation(), []); const slug = useMemo(() => parseSlug(slugRaw), [slugRaw]); @@ -669,13 +724,14 @@ export default function VerifyPage(): ReactElement { const [embeddedProof, setEmbeddedProof] = useState(null); const [notice, setNotice] = useState(""); - const [authorSigVerified, setAuthorSigVerified] = useState(null); + const [ownerAuthVerified, setOwnerAuthVerified] = useState(null); + const [ownerAuthStatus, setOwnerAuthStatus] = useState("Not present"); + const [ownerAuthBusy, setOwnerAuthBusy] = useState(false); + const [identityScanRequested, setIdentityScanRequested] = useState(false); + const [provenanceSigVerified, setProvenanceSigVerified] = useState(null); const [receiveSigVerified, setReceiveSigVerified] = useState(null); const [ownerPhiKeyVerified, setOwnerPhiKeyVerified] = useState(null); const [ownershipAttested, setOwnershipAttested] = useState("missing"); - const [identityAttested, setIdentityAttested] = useState("missing"); - const [identityScanRequested, setIdentityScanRequested] = useState(false); - const [identityScanBusy, setIdentityScanBusy] = useState(false); const [artifactAttested, setArtifactAttested] = useState("missing"); const [zkVerify, setZkVerify] = useState(null); @@ -691,8 +747,6 @@ export default function VerifyPage(): ReactElement { const [valuationHash, setValuationHash] = useState(""); const [receiveSig, setReceiveSig] = useState(null); - const [localReceiveBundle, setLocalReceiveBundle] = useState(null); - const [receiveBusy, setReceiveBusy] = useState(false); const [dragActive, setDragActive] = useState(false); @@ -776,26 +830,56 @@ export default function VerifyPage(): ReactElement { : "Live glyph valuation"; const isReceiveGlyph = useMemo(() => { - const mode = localReceiveBundle?.mode ?? embeddedProof?.mode ?? sharedReceipt?.mode; + const mode = embeddedProof?.mode ?? sharedReceipt?.mode; if (mode === "receive") return true; - if (localReceiveBundle?.receiveSig || embeddedProof?.receiveSig || sharedReceipt?.receiveSig) return true; - if (localReceiveBundle?.originBundleHash || embeddedProof?.originBundleHash || sharedReceipt?.originBundleHash) return true; - if (localReceiveBundle?.ownerPhiKey || embeddedProof?.ownerPhiKey || sharedReceipt?.ownerPhiKey) return true; + if (embeddedProof?.receiveSig || sharedReceipt?.receiveSig) return true; + if (embeddedProof?.originBundleHash || sharedReceipt?.originBundleHash) return true; + if (embeddedProof?.ownerPhiKey || sharedReceipt?.ownerPhiKey) return true; return false; }, [ embeddedProof?.mode, embeddedProof?.originBundleHash, embeddedProof?.ownerPhiKey, embeddedProof?.receiveSig, - localReceiveBundle?.mode, - localReceiveBundle?.originBundleHash, - localReceiveBundle?.ownerPhiKey, - localReceiveBundle?.receiveSig, sharedReceipt?.mode, sharedReceipt?.originBundleHash, sharedReceipt?.ownerPhiKey, sharedReceipt?.receiveSig, ]); + const effectiveOriginBundleHash = useMemo( + () => + embeddedProof?.originBundleHash ?? + sharedReceipt?.originBundleHash ?? + undefined, + [embeddedProof?.originBundleHash, sharedReceipt?.originBundleHash], + ); + const provenanceAuthorSig = useMemo( + () => + embeddedProof?.originAuthorSig ?? + sharedReceipt?.originAuthorSig ?? + null, + [embeddedProof?.originAuthorSig, sharedReceipt?.originAuthorSig], + ); + const hasKASProvenanceSig = useMemo( + () => hasRequiredKasAuthorSig(provenanceAuthorSig), + [provenanceAuthorSig], + ); + const ownerAuthorSig = useMemo( + () => embeddedProof?.authorSig ?? (result.status === "ok" ? result.embedded.authorSig ?? null : null), + [embeddedProof?.authorSig, result], + ); + const hasKASOwnerSig = useMemo( + () => hasRequiredKasAuthorSig(embeddedProof?.authorSig ?? (result.status === "ok" ? result.embedded.authorSig : null)), + [embeddedProof?.authorSig, result], + ); + const hasKASReceiveSig = useMemo(() => { + if (!receiveSig) return false; + return Boolean(receiveSig.credId && receiveSig.pubKeyJwk); + }, [receiveSig]); + const hasKASAuthSig = hasKASOwnerSig || hasKASReceiveSig; + const effectiveOwnerSig = ownerAuthorSig; + const isChildGlyphValue = + isChildGlyph(result.status === "ok" ? result.embedded.raw : null) || isChildGlyph(embeddedProof?.raw); // Focus Views @@ -872,6 +956,13 @@ export default function VerifyPage(): ReactElement { return () => window.clearTimeout(t); }, [notice]); + React.useEffect(() => { + if (result.status === "ok") return; + setOwnerAuthVerified(null); + setOwnerAuthStatus("Not present"); + setOwnerAuthBusy(false); + }, [result.status]); + const openChartPopover = useCallback((focus: "phi" | "usd") => { const nextFocus = isReceiveGlyph ? "usd" : focus; setChartFocus(nextFocus); @@ -892,9 +983,10 @@ export default function VerifyPage(): ReactElement { }, [setSealPopover]); const openKasPopover = useCallback(() => { + if (!hasKASAuthSig) return; setPanel("audit"); setSealPopover("kas"); - }, [setPanel, setSealPopover]); + }, [hasKASAuthSig, setPanel, setSealPopover]); const openG16Popover = useCallback(() => { setPanel("zk"); @@ -916,12 +1008,16 @@ export default function VerifyPage(): ReactElement { React.useEffect(() => { if (!sealPopover) return; + if (sealPopover === "kas" && !hasKASAuthSig) { + setSealPopover(null); + return; + } const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeSealPopover(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [closeSealPopover, sealPopover]); + }, [closeSealPopover, hasKASAuthSig, sealPopover]); const chartPhi = useMemo(() => { const candidate = displayPhi ?? liveValuePhi ?? 0; @@ -1004,7 +1100,7 @@ export default function VerifyPage(): ReactElement { const onPickFile = useCallback( async (file: File): Promise => { if (!isSvgFile(file)) { - setResult({ status: "error", message: "Upload a sealed .svg (embedded JSON).", slug }); + setResult({ status: "error", message: "inhale a sealed .svg (embedded JSON).", slug }); return; } const text = await readFileText(file); @@ -1015,24 +1111,201 @@ export default function VerifyPage(): ReactElement { [slug], ); + const onPickReceiptPng = useCallback( + async (file: File): Promise => { + if (!isPngFile(file)) { + setResult({ status: "error", message: "Select a receipt PNG with embedded proof metadata.", slug }); + return; + } + try { + const buffer = await readFileArrayBuffer(file); + const text = readPngTextChunk(new Uint8Array(buffer), "phi_proof_bundle"); + if (!text) { + setResult({ status: "error", message: "Receipt PNG is missing embedded proof metadata.", slug }); + return; + } + const parsed = JSON.parse(text) as unknown; + const receipt = buildSharedReceiptFromObject(parsed); + if (!receipt) { + setResult({ status: "error", message: "Receipt PNG contains an invalid proof bundle.", slug }); + return; + } + setSharedReceipt(receipt); + setSvgText(""); + setResult({ status: "idle" }); + setNotice("Receipt PNG loaded."); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to read receipt PNG."; + setResult({ status: "error", message: msg, slug }); + } + }, + [slug], + ); + const handleFiles = useCallback( (files: FileList | null | undefined): void => { if (!files || files.length === 0) return; const arr = Array.from(files); + const png = arr.find(isPngFile); + if (png) { + void onPickReceiptPng(png); + return; + } const svg = arr.find(isSvgFile); if (!svg) { - setResult({ status: "error", message: "Drop/select a sealed .svg file.", slug }); + setResult({ status: "error", message: "Drop/select a sealed .svg file or receipt PNG.", slug }); return; } void onPickFile(svg); }, - [onPickFile, slug], + [onPickFile, onPickReceiptPng, slug], + ); + + const runOwnerAuthFlow = useCallback( + async (args: { + ownerAuthorSig: KASAuthorSig | null; + glyphPhiKeyDeclared: string | null; + glyphPhiKeyFallback: string | null; + }): Promise => { + if (ownerAuthBusy) return; + setOwnerAuthVerified(null); + + const ownerAuthorSig = args.ownerAuthorSig; + if (!hasRequiredKasAuthorSig(ownerAuthorSig)) return; + + if (!isWebAuthnAvailable()) { + setOwnerAuthStatus("Authentication not completed."); + setNotice("WebAuthn is not available in this browser. Please verify on a device with passkeys enabled."); + return; + } + + const expectedCredId = + ownerAuthorSig.credId || (ownerAuthorSig as { rawId?: string }).rawId || ""; + if (!expectedCredId) { + setOwnerAuthVerified(false); + setOwnerAuthStatus("Steward mismatch."); + return; + } + + setOwnerAuthBusy(true); + setOwnerAuthStatus("Waiting for steward authentication…"); + const challengeBytes = crypto.getRandomValues(new Uint8Array(32)); + const requestAssertion = async ( + allowCredentials?: PublicKeyCredentialDescriptor[] + ): Promise => { + try { + const got = await navigator.credentials.get({ + publicKey: { + challenge: challengeBytes, + userVerification: "required", + timeout: 60_000, + ...(allowCredentials ? { allowCredentials } : {}), + }, + }); + return (got ?? null) as PublicKeyCredential | null; + } catch { + return null; + } + }; + + let allowCredentials: PublicKeyCredentialDescriptor[] | undefined; + if (expectedCredId) { + try { + const idBytes = base64UrlDecode(expectedCredId); + allowCredentials = [{ type: "public-key" as const, id: toArrayBuffer(idBytes) }]; + } catch { + allowCredentials = undefined; + } + } + + let assertion = await requestAssertion(allowCredentials); + if (!assertion) { + setOwnerAuthStatus("Searching for signer passkey…"); + assertion = await requestAssertion(); + } + + if (assertion) { + const assertionJson = assertionToJson(assertion); + const verified = await verifyOwnerWebAuthnAssertion({ + assertion: assertionJson, + expectedChallenge: challengeBytes, + pubKeyJwk: ownerAuthorSig.pubKeyJwk, + expectedCredId, + }); + + if (!verified) { + setOwnerAuthVerified(false); + setOwnerAuthStatus("Signer mismatch."); + setOwnerAuthBusy(false); + return; + } + + const declaredPhiKey = args.glyphPhiKeyDeclared; + if (declaredPhiKey) { + const signerPhiKey = await derivePhiKeyFromPubKeyJwk(ownerAuthorSig.pubKeyJwk); + if (signerPhiKey !== declaredPhiKey) { + setOwnerAuthVerified(false); + setOwnerAuthStatus("Signer mismatch."); + setOwnerAuthBusy(false); + return; + } + } + + const storePhiKey = args.glyphPhiKeyDeclared ?? args.glyphPhiKeyFallback; + if (storePhiKey) { + storePasskey(storePhiKey, { + credId: assertionJson.rawId, + pubKeyJwk: ownerAuthorSig.pubKeyJwk, + }); + } + setOwnerAuthVerified(true); + setOwnerAuthStatus("Steward verified"); + setOwnerAuthBusy(false); + return; + } + + setOwnerAuthVerified(false); + setOwnerAuthStatus("Steward credential not found on this device."); + setOwnerAuthBusy(false); + }, + [ownerAuthBusy], + ); + + const stampAuditFields = useCallback( + (params: { + nextResult: VerifyResult; + embeddedMeta?: ProofBundleMeta | null; + bundleHashValue?: string; + }): void => { + const bundleHashValue = params.bundleHashValue ?? ""; + if (params.nextResult.status !== "ok" || !bundleHashValue) { + setReceiveSig(null); + setReceiveSigVerified(null); + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } + + const embeddedReceive = + params.embeddedMeta?.receiveSig ?? + readReceiveSigFromBundle(params.embeddedMeta?.raw ?? params.nextResult.embedded.raw); + if (embeddedReceive) { + setReceiveSig(embeddedReceive); + return; + } + + setReceiveSig(null); + setReceiveSigVerified(null); + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + }, + [], ); const runVerify = useCallback(async (): Promise => { const raw = svgText.trim(); if (!raw) { - setResult({ status: "error", message: "Inhale or paste the sealed SVG (ΦKey).", slug }); + setResult({ status: "error", message: "Inhale or remember the sealed SVG (ΦKey).", slug }); return; } const receipt = parseSharedReceiptFromText(raw); @@ -1062,15 +1335,87 @@ if (receipt.receiptHash) { const next = await verifySigilSvg(slug, raw, verifiedAtPulse); setResult(next); if (next.status === "ok") { - setIdentityAttested("missing"); - setIdentityScanRequested(true); + const embeddedProofMeta = extractProofBundleMetaFromSvg(raw); + const ownerAuthorSig = embeddedProofMeta?.authorSig ?? next.embedded.authorSig ?? null; + const rawEmbeddedPhiKey = next.embeddedRawPhiKey ?? null; + const glyphPhiKeyDeclared = normalizeRawDeclaredPhiKey(rawEmbeddedPhiKey); + const glyphPhiKeyFallback = glyphPhiKeyDeclared ? null : next.derivedPhiKey ?? null; + if (hasRequiredKasAuthorSig(ownerAuthorSig)) { + await runOwnerAuthFlow({ ownerAuthorSig, glyphPhiKeyDeclared, glyphPhiKeyFallback }); + } + stampAuditFields({ + nextResult: next, + embeddedMeta: embeddedProofMeta, + bundleHashValue: embeddedProofMeta?.bundleHash ?? "", + }); } else { - setIdentityScanRequested(false); + setOwnerAuthVerified(null); + setOwnerAuthStatus("Not present"); } } finally { setBusy(false); } - }, [currentPulse, slug, svgText]); + }, [currentPulse, runOwnerAuthFlow, slug, stampAuditFields, svgText]); + + const identityAttested: AttestationState = hasKASOwnerSig ? (ownerAuthVerified === null ? "missing" : ownerAuthVerified) : "missing"; + + const autoScanContext = useMemo(() => { + if (!sharedReceipt) return null; + const authorSig = embeddedProof?.authorSig ?? sharedReceipt.authorSig ?? null; + if (!hasRequiredKasAuthorSig(authorSig)) return null; + const bundleHashValue = sharedReceipt.bundleHash ?? bundleHash; + if (!bundleHashValue) return null; + const expectedCredId = authorSig.credId || (authorSig as { rawId?: string }).rawId || ""; + if (!expectedCredId) return null; + try { + base64UrlDecode(expectedCredId); + } catch { + return null; + } + const authorSigBundleHash = bundleHashFromAuthorSig(authorSig) ?? ""; + return { authorSig, bundleHashValue, authorSigBundleHash, expectedCredId }; + }, [bundleHash, embeddedProof?.authorSig, sharedReceipt]); + + const autoScanFallbackPhiKey = useMemo( + () => (sharedReceipt?.proofCapsule?.phiKey ? sharedReceipt.proofCapsule.phiKey : null), + [sharedReceipt?.proofCapsule?.phiKey], + ); + + React.useEffect(() => { + if (!hasKASOwnerSig) return; + if (identityAttested !== "missing") return; + if (!autoScanContext) return; + const autoScanKey = `${autoScanContext.bundleHashValue}|${autoScanContext.authorSigBundleHash}|${autoScanContext.expectedCredId}`; + if (lastAutoScanKeyRef.current === autoScanKey) return; + lastAutoScanKeyRef.current = autoScanKey; + setIdentityScanRequested(true); + }, [autoScanContext, hasKASOwnerSig, identityAttested]); + + React.useEffect(() => { + if (!hasKASOwnerSig) return; + if (!identityScanRequested) return; + if (!autoScanContext) return; + void runOwnerAuthFlow({ + ownerAuthorSig: autoScanContext.authorSig, + glyphPhiKeyDeclared: null, + glyphPhiKeyFallback: autoScanFallbackPhiKey, + }); + setIdentityScanRequested(false); + }, [autoScanContext, autoScanFallbackPhiKey, hasKASOwnerSig, identityScanRequested, runOwnerAuthFlow]); + + React.useEffect(() => { + if (!hasKASOwnerSig) { + if (identityScanRequested) setIdentityScanRequested(false); + return; + } + if (identityAttested !== "missing") { + if (identityScanRequested) setIdentityScanRequested(false); + return; + } + if (!autoScanContext && identityScanRequested) { + setIdentityScanRequested(false); + } + }, [autoScanContext, hasKASOwnerSig, identityAttested, identityScanRequested]); // Proof bundle construction (logic unchanged) React.useEffect(() => { @@ -1084,7 +1429,7 @@ if (receipt.receiptHash) { setBundleRoot(null); setBundleHash(""); setEmbeddedProof(null); - setAuthorSigVerified(null); + setProvenanceSigVerified(null); setVerificationCacheEntry(null); setZkVerifiedCached(false); setVerificationSig(null); @@ -1118,7 +1463,7 @@ if (receipt.receiptHash) { proofHints: sharedReceipt.proofHints, zkPublicInputs: sharedReceipt.zkPublicInputs, }); - setAuthorSigVerified(null); + setProvenanceSigVerified(null); return; } @@ -1150,7 +1495,7 @@ if (receipt.receiptHash) { const verificationVersionValue = embedded?.verificationVersion; const proofHintsValue = embedded?.transport?.proofHints ?? embedded?.proofHints; const embeddedMode = embedded?.mode; - const embeddedOriginSig = embedded?.originAuthorSig; + const provenanceAuthorSig = embedded?.originAuthorSig; const bundleSeed = embedded?.raw && typeof embedded.raw === "object" && embedded.raw !== null @@ -1185,12 +1530,12 @@ if (receipt.receiptHash) { mode: embeddedMode, originBundleHash: embedded?.originBundleHash, receiveBundleHash: embedded?.receiveBundleHash, - originAuthorSig: embeddedOriginSig ?? null, + originAuthorSig: provenanceAuthorSig ?? null, receiveSig: embedded?.receiveSig ?? null, receivePulse: embedded?.receivePulse, ownerPhiKey: embedded?.ownerPhiKey, ownerKeyDerivation: embedded?.ownerKeyDerivation, - authorSig: embeddedMode === "receive" || embeddedOriginSig ? null : embedded?.authorSig ?? null, + authorSig: embedded?.authorSig ?? null, }; const bundleRootNext = buildBundleRoot(bundleSeed); @@ -1211,24 +1556,12 @@ if (receipt.receiptHash) { embedded?.bindings?.bundleHashOf === PROOF_BINDINGS.bundleHashOf; const bundleHashNext = useRootHash ? rootHash : legacyHash; - const authorSigNext = embeddedOriginSig ?? embedded?.authorSig; - let authorSigOk: boolean | null = null; - - if (embeddedOriginSig) { - if (!embedded?.originBundleHash || !isKASAuthorSig(embeddedOriginSig)) { - authorSigOk = false; - } else { - authorSigOk = await verifyBundleAuthorSig(embedded.originBundleHash, embeddedOriginSig); - } - } else if (authorSigNext) { - if (isKASAuthorSig(authorSigNext)) { - const authorSigBundleHash = bundleHashFromAuthorSig(authorSigNext); - const candidateHashes = Array.from( - new Set([authorSigBundleHash, bundleHashNext, rootHash, legacyHash].filter(Boolean)) - ) as string[]; - authorSigOk = await verifyAuthorSigWithFallback(authorSigNext, candidateHashes); + let provenanceSigOk: boolean | null = null; + if (provenanceAuthorSig) { + if (!embedded?.originBundleHash || !hasRequiredKasAuthorSig(provenanceAuthorSig)) { + provenanceSigOk = null; } else { - authorSigOk = false; + provenanceSigOk = await verifyBundleAuthorSig(embedded.originBundleHash, provenanceAuthorSig); } } @@ -1239,7 +1572,7 @@ if (receipt.receiptHash) { setBundleRoot(useRootHash ? bundleRootNext : embedded?.bundleRoot ?? null); setBundleHash(bundleHashNext); setEmbeddedProof(embedded); - setAuthorSigVerified(authorSigOk); + setProvenanceSigVerified(provenanceSigOk); }; void buildProof(); @@ -1323,6 +1656,7 @@ if (receipt.receiptHash) { embedded: baseEmbedded, derivedPhiKey, checks, + embeddedRawPhiKey: capsule.phiKey, } : { status: "ok", @@ -1331,6 +1665,7 @@ if (receipt.receiptHash) { derivedPhiKey, checks, verifiedAtPulse, + embeddedRawPhiKey: capsule.phiKey, }, ); setEmbeddedProof(embed); @@ -1376,29 +1711,8 @@ React.useEffect(() => { }, [embeddedProof?.verificationSig, sharedReceipt?.verificationSig, bundleHash]); React.useEffect(() => { - if (result.status !== "ok" || !bundleHash) { - setReceiveSig(null); - setReceiveSigVerified(null); - setOwnerPhiKeyVerified(null); - setOwnershipAttested("missing"); - return; - } - const embeddedReceive = embeddedProof?.receiveSig ?? readReceiveSigFromBundle(embeddedProof?.raw ?? result.embedded.raw); - if (embeddedReceive) { - setReceiveSig(embeddedReceive); - return; - } - - setReceiveSig(null); - setReceiveSigVerified(null); - setOwnerPhiKeyVerified(null); - setOwnershipAttested("missing"); - }, [result.status, bundleHash, embeddedProof?.raw]); - - React.useEffect(() => { - setLocalReceiveBundle(null); - }, [bundleHash, svgText]); - + stampAuditFields({ nextResult: result, embeddedMeta: embeddedProof, bundleHashValue: bundleHash }); + }, [bundleHash, embeddedProof, result, stampAuditFields]); React.useEffect(() => { let active = true; @@ -1415,13 +1729,6 @@ React.useEffect(() => { }; }, [receiptHash, verificationSig]); - React.useEffect(() => { - if (!svgText.trim()) { - setIdentityAttested("missing"); - setIdentityScanRequested(false); - } - }, [svgText]); - React.useEffect(() => { const raw = svgText.trim(); if (!raw) { @@ -1626,48 +1933,6 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le zkVkey, ]); - const attemptIdentityScan = useCallback( - async (authorSig: KASAuthorSig, bundleHashValue: string): Promise => { - if (identityScanBusy) return; - setIdentityScanBusy(true); - try { - if (!isWebAuthnAvailable()) { - setIdentityAttested(false); - setNotice("WebAuthn is not available in this browser. Please verify on a device with passkeys enabled."); - return; - } - const { challengeBytes } = await buildKasChallenge("unlock", bundleHashValue); - let assertion: Awaited>; - try { - assertion = await getWebAuthnAssertionJson({ - challenge: challengeBytes, - allowCredIds: [authorSig.credId], - preferInternal: true, - }); - } catch { - assertion = await getWebAuthnAssertionJson({ - challenge: challengeBytes, - preferInternal: true, - }); - } - const ok = await verifyWebAuthnAssertion({ - assertion, - expectedChallenge: challengeBytes, - pubKeyJwk: authorSig.pubKeyJwk, - expectedCredId: authorSig.credId, - }); - setIdentityAttested(ok); - if (!ok) setNotice("Identity verification failed."); - } catch { - setIdentityAttested(false); - setNotice("Identity verification canceled."); - } finally { - setIdentityScanBusy(false); - setIdentityScanRequested(false); - } - }, - [identityScanBusy] - ); const verificationReceipt = useMemo(() => { if (!bundleHash || !zkMeta?.zkPoseidonHash || stewardVerifiedPulse == null) return null; const valuationPayload = valuationSnapshot && valuationHash ? { valuation: valuationSnapshot, valuationHash } : undefined; @@ -1681,26 +1946,31 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le }); }, [bundleHash, stewardVerifiedPulse, valuationHash, valuationSnapshot, verificationSource, verificationVersion, zkMeta?.zkPoseidonHash]); - const effectiveReceiveSig = useMemo(() => localReceiveBundle?.receiveSig ?? receiveSig ?? null, [localReceiveBundle?.receiveSig, receiveSig]); + const effectiveReceiveSig = useMemo(() => receiveSig ?? null, [receiveSig]); const effectiveReceivePulse = useMemo(() => { - if (localReceiveBundle?.receivePulse != null) return localReceiveBundle.receivePulse; if (embeddedProof?.receivePulse != null) return embeddedProof.receivePulse; if (sharedReceipt?.receivePulse != null) return sharedReceipt.receivePulse; return effectiveReceiveSig?.createdAtPulse ?? null; - }, [embeddedProof?.receivePulse, localReceiveBundle?.receivePulse, sharedReceipt?.receivePulse, effectiveReceiveSig?.createdAtPulse]); + }, [ + embeddedProof?.receivePulse, + sharedReceipt?.receivePulse, + effectiveReceiveSig?.createdAtPulse, + ]); const effectiveReceiveBundleHash = useMemo(() => { - if (localReceiveBundle?.receiveBundleHash) return localReceiveBundle.receiveBundleHash; if (embeddedProof?.receiveBundleHash) return embeddedProof.receiveBundleHash; if (sharedReceipt?.receiveBundleHash) return sharedReceipt.receiveBundleHash; if (effectiveReceiveSig?.binds.bundleHash) return effectiveReceiveSig.binds.bundleHash; return ""; - }, [embeddedProof?.receiveBundleHash, effectiveReceiveSig?.binds.bundleHash, localReceiveBundle?.receiveBundleHash, sharedReceipt?.receiveBundleHash]); + }, [ + embeddedProof?.receiveBundleHash, + effectiveReceiveSig?.binds.bundleHash, + sharedReceipt?.receiveBundleHash, + ]); const effectiveReceiveMode = useMemo(() => { - if (localReceiveBundle?.mode) return localReceiveBundle.mode; if (embeddedProof?.mode) return embeddedProof.mode; if (sharedReceipt?.mode) return sharedReceipt.mode; return effectiveReceiveSig ? "receive" : undefined; - }, [embeddedProof?.mode, effectiveReceiveSig, localReceiveBundle?.mode, sharedReceipt?.mode]); + }, [embeddedProof?.mode, effectiveReceiveSig, sharedReceipt?.mode]); React.useEffect(() => { if (typeof window === "undefined") return; @@ -1714,14 +1984,13 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le if (result.status === "ok") { ogImageUrl.searchParams.set("pulse", String(result.embedded.pulse ?? slug.pulse ?? "")); const ogPhiKey = - localReceiveBundle?.ownerPhiKey ?? embeddedProof?.ownerPhiKey ?? sharedReceipt?.ownerPhiKey ?? result.derivedPhiKey ?? ""; ogImageUrl.searchParams.set("phiKey", ogPhiKey); if (result.embedded.chakraDay) ogImageUrl.searchParams.set("chakraDay", result.embedded.chakraDay); - const kasStatus = effectiveReceiveSig ? receiveSigVerified : authorSigVerified; + const kasStatus = ownerAuthVerified; if (kasStatus != null) ogImageUrl.searchParams.set("kas", kasStatus ? "1" : "0"); if (zkVerify != null) ogImageUrl.searchParams.set("g16", zkVerify ? "1" : "0"); } @@ -1736,10 +2005,9 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le ensureMetaTag("name", "twitter:description", `Proof of Breath™ • ${statusLabel} • Pulse ${slug.pulse ?? "—"}`); ensureMetaTag("name", "twitter:image", ogImageUrl.toString()); }, [ - authorSigVerified, + ownerAuthVerified, effectiveReceiveSig, embeddedProof?.ownerPhiKey, - localReceiveBundle?.ownerPhiKey, receiveSigVerified, result, sharedReceipt?.ownerPhiKey, @@ -1748,37 +2016,34 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le slugRaw, zkVerify, ]); - const effectiveOriginBundleHash = useMemo( - () => - localReceiveBundle?.originBundleHash ?? - embeddedProof?.originBundleHash ?? - sharedReceipt?.originBundleHash ?? - undefined, - [embeddedProof?.originBundleHash, localReceiveBundle?.originBundleHash, sharedReceipt?.originBundleHash], - ); - const effectiveOriginAuthorSig = useMemo( - () => - localReceiveBundle?.originAuthorSig ?? - embeddedProof?.originAuthorSig ?? - sharedReceipt?.originAuthorSig ?? - null, - [embeddedProof?.originAuthorSig, localReceiveBundle?.originAuthorSig, sharedReceipt?.originAuthorSig], - ); + React.useEffect(() => { + if (!hasKASOwnerSig) { + setOwnerAuthVerified(null); + setOwnerAuthStatus("Not present"); + setOwnerAuthBusy(false); + } + if ((isReceiveGlyph || isChildGlyphValue) && effectiveOwnerSig && provenanceAuthorSig && effectiveOwnerSig === provenanceAuthorSig) { + throw new Error("Invariant violation: provenance authorSig cannot be used as owner for receive/child glyphs."); + } + }, [effectiveOwnerSig, embeddedProof?.raw, hasKASOwnerSig, isChildGlyphValue, isReceiveGlyph, provenanceAuthorSig, result]); const effectiveOwnerPhiKey = useMemo( () => - localReceiveBundle?.ownerPhiKey ?? embeddedProof?.ownerPhiKey ?? sharedReceipt?.ownerPhiKey ?? - undefined, - [embeddedProof?.ownerPhiKey, localReceiveBundle?.ownerPhiKey, sharedReceipt?.ownerPhiKey], + (effectiveReceiveSig ? undefined : result.status === "ok" ? result.derivedPhiKey : undefined), + [ + embeddedProof?.ownerPhiKey, + sharedReceipt?.ownerPhiKey, + effectiveReceiveSig, + result, + ], ); const effectiveOwnerKeyDerivation = useMemo( () => - localReceiveBundle?.ownerKeyDerivation ?? embeddedProof?.ownerKeyDerivation ?? sharedReceipt?.ownerKeyDerivation ?? undefined, - [embeddedProof?.ownerKeyDerivation, localReceiveBundle?.ownerKeyDerivation, sharedReceipt?.ownerKeyDerivation], + [embeddedProof?.ownerKeyDerivation, sharedReceipt?.ownerKeyDerivation], ); const receiveBundleRoot = useMemo(() => { @@ -1801,7 +2066,7 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot ?? undefined, bundle: bundleSeed, originBundleHash: effectiveOriginBundleHash ?? bundleHash ?? undefined, - originAuthorSig: effectiveOriginAuthorSig ?? null, + originAuthorSig: provenanceAuthorSig ?? null, receivePulse: effectiveReceivePulse ?? undefined, }); }, [ @@ -1814,7 +2079,7 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le embeddedProof?.hashAlg, embeddedProof?.zkMeta, embeddedProof?.zkStatement, - effectiveOriginAuthorSig, + provenanceAuthorSig, effectiveOriginBundleHash, effectiveReceivePulse, proofCapsule, @@ -1826,15 +2091,40 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le React.useEffect(() => { let active = true; + if (!hasKASAuthSig) { + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } const receiveMode = effectiveReceiveMode === "receive"; if (!receiveMode && !effectiveReceiveSig) { + if (ownerAuthVerified === true) { + setOwnerPhiKeyVerified(true); + setOwnershipAttested(true); + return; + } + if (ownerAuthVerified === false) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } setOwnerPhiKeyVerified(null); setOwnershipAttested("missing"); return; } if (!effectiveReceiveSig) { + if (ownerAuthVerified === true) { + setOwnerPhiKeyVerified(true); + setOwnershipAttested(true); + return; + } + if (ownerAuthVerified === false) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } setOwnerPhiKeyVerified(null); setOwnershipAttested("missing"); return; @@ -1923,74 +2213,52 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le effectiveReceiveMode, effectiveReceivePulse, effectiveReceiveSig, + hasKASAuthSig, receiveBundleRoot, receiveSigVerified, + ownerAuthVerified, ]); React.useEffect(() => { let active = true; if (!bundleHash) return; - const originSig = effectiveOriginAuthorSig; - const legacySig = embeddedProof?.authorSig; + if (isReceiveGlyph) { + setProvenanceSigVerified(null); + return; + } + const originSig = provenanceAuthorSig; - if (!originSig && !legacySig) { - setAuthorSigVerified(null); + if (!originSig || !hasKASProvenanceSig) { + setProvenanceSigVerified(null); return; } (async () => { - if (originSig) { - if (!isKASAuthorSig(originSig)) { - if (active) setAuthorSigVerified(false); - return; - } - const derivedHash = bundleHashFromAuthorSig(originSig); - const originBundleHash = effectiveOriginBundleHash ?? (derivedHash && derivedHash === bundleHash ? derivedHash : null); - const candidateHashes = originBundleHash ? [originBundleHash] : []; - if (!candidateHashes.length) { - if (active) setAuthorSigVerified(false); - return; - } - const ok = await verifyAuthorSigWithFallback(originSig, candidateHashes); - if (active) setAuthorSigVerified(ok); + if (!isKASAuthorSig(originSig)) { + if (active) setProvenanceSigVerified(false); return; } - - if (!legacySig || !isKASAuthorSig(legacySig)) { - if (active) setAuthorSigVerified(false); + const derivedHash = bundleHashFromAuthorSig(originSig); + const originBundleHash = + effectiveOriginBundleHash ?? (derivedHash && derivedHash === bundleHash ? derivedHash : null); + const candidateHashes = originBundleHash ? [originBundleHash] : []; + if (!candidateHashes.length) { + if (active) setProvenanceSigVerified(false); return; } - const authorBundleHash = bundleHashFromAuthorSig(legacySig); - const candidateHashes = Array.from(new Set([authorBundleHash, bundleHash].filter(Boolean))) as string[]; - const ok = await verifyAuthorSigWithFallback(legacySig, candidateHashes); - if (active) setAuthorSigVerified(ok); + const ok = await verifyAuthorSigWithFallback(originSig, candidateHashes); + if (active) setProvenanceSigVerified(ok); })(); return () => { active = false; }; - }, [bundleHash, embeddedProof?.authorSig, effectiveOriginAuthorSig, effectiveOriginBundleHash]); + }, [bundleHash, provenanceAuthorSig, effectiveOriginBundleHash, hasKASProvenanceSig, isReceiveGlyph]); const receiveCredId = useMemo(() => (effectiveReceiveSig ? effectiveReceiveSig.credId : ""), [effectiveReceiveSig]); const receiveNonce = useMemo(() => (effectiveReceiveSig?.nonce ? effectiveReceiveSig.nonce : ""), [effectiveReceiveSig?.nonce]); const receiveBundleHash = useMemo(() => effectiveReceiveBundleHash, [effectiveReceiveBundleHash]); - React.useEffect(() => { - if (!identityScanRequested) return; - if (!svgText.trim()) { - setIdentityScanRequested(false); - return; - } - const authorSig = effectiveOriginAuthorSig ?? embeddedProof?.authorSig; - if (!authorSig || !isKASAuthorSig(authorSig)) { - setIdentityAttested("missing"); - setIdentityScanRequested(false); - return; - } - if (!bundleHash) return; - void attemptIdentityScan(authorSig, bundleHash); - }, [attemptIdentityScan, bundleHash, embeddedProof?.authorSig, effectiveOriginAuthorSig, identityScanRequested, svgText]); - const badge: { kind: BadgeKind; title: string; subtitle?: string } = useMemo(() => { if (busy) return { kind: "busy", title: "SEALING", subtitle: "Deterministic proof rails executing." }; if (result.status === "ok") return { kind: "ok", title: "PROOF OF BREATH™", subtitle: "Human-origin seal affirmed." }; @@ -2008,21 +2276,30 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le }, [effectiveOwnerPhiKey, result]); const kpiPhiKey = useMemo(() => effectivePhiKey, [effectivePhiKey]); - const provenanceSig = useMemo( - () => effectiveOriginAuthorSig ?? embeddedProof?.authorSig ?? null, - [embeddedProof?.authorSig, effectiveOriginAuthorSig], - ); + const provenanceSig = hasKASProvenanceSig ? provenanceAuthorSig : null; + const provenanceSigVerifiedValue = hasKASProvenanceSig ? provenanceSigVerified : null; + + const ownerAuthSignerPresent = hasKASAuthSig && Boolean(effectiveOwnerSig || effectiveReceiveSig); + const ownerAuthVerifiedValue = useMemo(() => { + if (!hasKASAuthSig) return null; + if (effectiveOwnerSig) return ownerAuthVerified; + if (effectiveReceiveSig) return receiveSigVerified; + return null; + }, [effectiveOwnerSig, effectiveReceiveSig, hasKASAuthSig, ownerAuthVerified, receiveSigVerified]); const sealKAS: SealState = useMemo(() => { - if (busy) return "busy"; + if (!hasKASAuthSig) return "off"; + if (busy || ownerAuthBusy) return "busy"; + if (ownerAuthorSig) { + if (ownerAuthVerified === null) return "na"; + return ownerAuthVerified ? "valid" : "invalid"; + } if (effectiveReceiveSig) { if (receiveSigVerified === null) return "na"; return receiveSigVerified ? "valid" : "invalid"; } - if (!provenanceSig) return "off"; - if (authorSigVerified === null) return "na"; - return authorSigVerified ? "valid" : "invalid"; - }, [authorSigVerified, busy, effectiveReceiveSig, provenanceSig, receiveSigVerified]); + return "off"; + }, [busy, hasKASAuthSig, ownerAuthBusy, ownerAuthVerified, ownerAuthorSig, effectiveReceiveSig, receiveSigVerified]); const sealZK: SealState = useMemo(() => { if (busy) return "busy"; @@ -2054,61 +2331,6 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le return `${zkStatementValue.publicInputsContract.arity} • ${zkStatementValue.publicInputsContract.invariant}`; }, [zkStatementValue?.publicInputsContract]); - const verifiedCardData = useMemo(() => { - if (result.status !== "ok" || !proofCapsule || !capsuleHash || stewardVerifiedPulse == null) return null; - const receiptValue = verificationReceipt ?? embeddedProof?.receipt ?? sharedReceipt?.receipt; - const receiptHashValue = receiptHash || embeddedProof?.receiptHash || sharedReceipt?.receiptHash; - const verificationSigValue = verificationSig ?? embeddedProof?.verificationSig ?? sharedReceipt?.verificationSig; - const valuationValue = valuationSnapshot && valuationHash ? { ...valuationSnapshot, valuationHash } : undefined; - return { - capsuleHash, - pulse: proofCapsule.pulse, - verifiedAtPulse: stewardVerifiedPulse, - phikey: effectivePhiKey !== "—" ? effectivePhiKey : proofCapsule.phiKey, - kasOk: sealKAS === "valid", - g16Ok: sealZK === "valid", - verifierSlug: proofCapsule.verifierSlug, - verifier: verificationSource, - verificationVersion, - bundleHash: bundleHash || undefined, - zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, - receipt: receiptValue ?? undefined, - receiptHash: receiptHashValue || undefined, - verificationSig: verificationSigValue ?? undefined, - sigilSvg: svgText.trim() ? svgText : undefined, - valuation: valuationValue, - }; - }, [ - bundleHash, - capsuleHash, - embeddedProof?.receipt, - embeddedProof?.receiptHash, - embeddedProof?.verificationSig, - proofCapsule, - receiptHash, - result.status, - effectivePhiKey, - sealKAS, - sealZK, - sharedReceipt?.receipt, - sharedReceipt?.receiptHash, - sharedReceipt?.verificationSig, - stewardVerifiedPulse, - svgText, - valuationHash, - valuationSnapshot, - verificationReceipt, - verificationSig, - verificationSource, - verificationVersion, - zkMeta?.zkPoseidonHash, - ]); - - const onDownloadVerifiedCard = useCallback(async () => { - if (!verifiedCardData) return; - await downloadVerifiedCardPng(verifiedCardData); - }, [verifiedCardData]); - const onSignVerification = useCallback(async () => { if (!proofCapsule || !receiptHash) return; if (verificationSigBusy) return; @@ -2118,7 +2340,8 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le } setVerificationSigBusy(true); try { - const kasSig = await signBundleHash(proofCapsule.phiKey, receiptHash); + const kasPhiKey = effectiveOwnerPhiKey ?? proofCapsule.phiKey; + const kasSig = await signBundleHash(kasPhiKey, receiptHash); const nextSig = verificationSigFromKas(kasSig); const ok = await verifyVerificationSig(receiptHash, nextSig); setVerificationSig(nextSig); @@ -2130,125 +2353,7 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le } finally { setVerificationSigBusy(false); } - }, [proofCapsule, receiptHash, verificationSigBusy]); - - const onReceiveGlyph = useCallback(async () => { - if (!bundleHash || !proofCapsule || !capsuleHash || !svgHash) return; - if (receiveBusy) return; - if (!isWebAuthnAvailable()) { - setNotice("WebAuthn is not available in this browser. Please verify on a device with passkeys enabled."); - return; - } - - setReceiveBusy(true); - try { - const receivePulse = currentPulse ?? getKaiPulseEternalInt(new Date()); - const originSigCandidate = effectiveOriginAuthorSig ?? (embeddedProof?.authorSig ?? null); - const originAuthorSig = isKASAuthorSig(originSigCandidate) ? originSigCandidate : null; - const originBundleHash = effectiveOriginBundleHash ?? bundleHash; - const receiveBundleSeed: ProofBundleLike = { - hashAlg: embeddedProof?.hashAlg ?? PROOF_HASH_ALG, - canon: embeddedProof?.canon ?? PROOF_CANON, - bindings: embeddedProof?.bindings ?? PROOF_BINDINGS, - zkStatement: embeddedProof?.zkStatement, - bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot, - zkMeta: embeddedProof?.zkMeta, - proofCapsule, - capsuleHash, - svgHash, - zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, - zkProof: zkMeta?.zkProof ?? undefined, - zkPublicInputs: zkMeta?.zkPublicInputs ?? undefined, - }; - const receiveBundleRoot = buildReceiveBundleRoot({ - bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot ?? undefined, - bundle: receiveBundleSeed, - originBundleHash, - originAuthorSig, - receivePulse, - }); - const receiveBundleHash = await hashReceiveBundleRoot(receiveBundleRoot); - const passkey = await ensureReceiverPasskey(); - const { nonce, challengeBytes } = await buildKasChallenge("receive", receiveBundleHash); - const assertion = await getWebAuthnAssertionJson({ - challenge: challengeBytes, - allowCredIds: [passkey.credId], - preferInternal: true, - }); - const ok = await verifyWebAuthnAssertion({ - assertion, - expectedChallenge: challengeBytes, - pubKeyJwk: passkey.pubKeyJwk, - expectedCredId: passkey.credId, - }); - if (!ok) { - setNotice("Receive signature invalid."); - return; - } - - const nextSig: ReceiveSig = { - v: "KRS-1", - alg: "webauthn-es256", - nonce, - binds: { bundleHash: receiveBundleHash }, - createdAtPulse: receivePulse, - credId: passkey.credId, - pubKeyJwk: passkey.pubKeyJwk as ReceiveSig["pubKeyJwk"], - assertion, - }; - - const ownerPhiKey = await deriveOwnerPhiKeyFromReceive({ - receiverPubKeyJwk: nextSig.pubKeyJwk, - receivePulse, - receiveBundleHash, - }); - const ownerKeyDerivation = buildOwnerKeyDerivation({ - originPhiKey: proofCapsule?.phiKey, - receivePulse, - receiveBundleHash, - }); - - setReceiveSig(nextSig); - setReceiveSigVerified(true); - setLocalReceiveBundle({ - mode: "receive", - originBundleHash, - receiveBundleHash, - originAuthorSig, - receiveSig: nextSig, - receivePulse, - ownerPhiKey, - ownerKeyDerivation, - }); - setNotice("Receive signature recorded."); - } catch (err) { - const msg = err instanceof Error ? err.message : "Receive claim canceled."; - setNotice(msg); - } finally { - setReceiveBusy(false); - } - }, [ - bundleHash, - bundleRoot, - capsuleHash, - currentPulse, - effectiveOriginAuthorSig, - effectiveOriginBundleHash, - embeddedProof?.authorSig, - embeddedProof?.bindings, - embeddedProof?.bundleRoot, - embeddedProof?.canon, - embeddedProof?.hashAlg, - embeddedProof?.zkMeta, - embeddedProof?.zkStatement, - proofCapsule?.phiKey, - proofCapsule, - receiveBusy, - svgHash, - zkMeta?.zkPoseidonHash, - zkMeta?.zkProof, - zkMeta?.zkPublicInputs, - ]); + }, [effectiveOwnerPhiKey, proofCapsule, receiptHash, verificationSigBusy]); const sealStateLabel = useCallback((state: SealState): string => { switch (state) { @@ -2281,6 +2386,7 @@ body: [ }; } if (sealPopover === "kas") { + if (!hasKASAuthSig) return null; return { title: "KAS • Kai Author Signature", status: sealStateLabel(sealKAS), @@ -2302,21 +2408,11 @@ body: [ "This seal represents cryptographic finality—provable integrity with privacy preserved." ], }; - }, [result.status, sealKAS, sealPopover, sealStateLabel, sealZK]); + }, [hasKASAuthSig, result.status, sealKAS, sealPopover, sealStateLabel, sealZK]); const hasSvgBytes = Boolean(svgText.trim()); const expectedSvgHash = sharedReceipt?.svgHash ?? embeddedProof?.svgHash ?? ""; - const hasKasIdentity = Boolean(provenanceSig && isKASAuthorSig(provenanceSig)); - const identityStatusLabel = - !hasSvgBytes || !hasKasIdentity - ? "Not present" - : identityScanBusy - ? "Aligning…" - : identityAttested === true - ? "Present (Verified)" - : identityAttested === false - ? "Not verified" - : "Alignment required"; + const identityStatusLabel = hasKASOwnerSig ? ownerAuthStatus || "Not present" : ""; const artifactStatusLabel = artifactAttested === true ? "Present (Verified)" @@ -2342,19 +2438,21 @@ body: [ const shareStatus = result.status === "ok" ? "VERIFIED" : result.status === "error" ? "FAILED" : "STANDBY"; const sharePhiShort = verifierPhi && verifierPhi !== "—" ? ellipsizeMiddle(verifierPhi, 12, 10) : "—"; - const shareKas = sealKAS === "valid" ? "✅" : "❌"; + const shareKas = hasKASAuthSig ? (sealKAS === "valid" ? "✅" : "❌") : null; const shareG16 = sealZK === "valid" ? "✅" : "❌"; const verificationSigLabel = verificationSigVerified === true ? "Verification signed" : verificationSigVerified === false ? "Verification signature invalid" : "Sign Verification"; const canSignVerification = Boolean(receiptHash && proofCapsule); - const canReceiveGlyph = Boolean(bundleHash && result.status === "ok"); - const receiveActionLabel = effectiveReceiveSig ? "Ownership received" : "Receive / Accept Glyph"; const stewardPulseLabel = stewardVerifiedPulse == null ? "Verified pulse unavailable (legacy bundle)" : `Steward Verified @ Pulse ${stewardVerifiedPulse}`; React.useEffect(() => { let active = true; + if (!hasKASReceiveSig) { + setReceiveSigVerified(null); + return; + } if (!effectiveReceiveSig) { setReceiveSigVerified(null); return; @@ -2380,7 +2478,7 @@ body: [ return () => { active = false; }; - }, [effectiveReceiveBundleHash, effectiveReceiveSig]); + }, [effectiveReceiveBundleHash, effectiveReceiveSig, hasKASReceiveSig]); React.useEffect(() => { @@ -2425,8 +2523,8 @@ React.useEffect(() => { }; }, [bundleHash, cacheVerificationVersion, zkMeta?.zkPoseidonHash]); - const auditBundleText = useMemo(() => { - if (!proofCapsule) return ""; + const auditBundlePayload = useMemo(() => { + if (!proofCapsule) return null; const transport = { shareUrl: embeddedProof?.transport?.shareUrl ?? embeddedProof?.shareUrl, verifierUrl: embeddedProof?.transport?.verifierUrl ?? proofVerifierUrl, @@ -2446,7 +2544,7 @@ React.useEffect(() => { ...(zkVerifiedCached ? { zkVerifiedCached: true } : {}), } : embeddedProof?.verificationCache ?? sharedReceipt?.verificationCache; - const authorSigForExport = effectiveReceiveMode === "receive" || effectiveOriginAuthorSig ? null : embeddedProof?.authorSig ?? null; + const authorSigForExport = embeddedProof?.authorSig ?? null; const normalized = normalizeBundle({ hashAlg: PROOF_HASH_ALG, canon: PROOF_CANON, @@ -2469,7 +2567,7 @@ React.useEffect(() => { mode: effectiveReceiveMode, originBundleHash: effectiveOriginBundleHash, receiveBundleHash: effectiveReceiveBundleHash, - originAuthorSig: effectiveOriginAuthorSig ?? null, + originAuthorSig: provenanceAuthorSig ?? null, receiveSig: effectiveReceiveSig ?? null, receivePulse: effectiveReceivePulse ?? undefined, ownerPhiKey: effectiveOwnerPhiKey ?? undefined, @@ -2487,15 +2585,20 @@ React.useEffect(() => { transport, }); - const withZkVerified = typeof zkVerified === "boolean" ? { ...normalized, zkVerified } : normalized; - return JSON.stringify(withZkVerified, null, 2); + const verifiedAtPulseValue = + typeof transport.verifiedAtPulse === "number" && Number.isFinite(transport.verifiedAtPulse) + ? transport.verifiedAtPulse + : stewardVerifiedPulse ?? undefined; + const withPulse = + typeof verifiedAtPulseValue === "number" ? { ...normalized, verifiedAtPulse: verifiedAtPulseValue } : normalized; + return typeof zkVerified === "boolean" ? { ...withPulse, zkVerified } : withPulse; }, [ proofCapsule, capsuleHash, svgHash, bundleHash, embeddedProof, - effectiveOriginAuthorSig, + provenanceAuthorSig, effectiveOriginBundleHash, effectiveOwnerKeyDerivation, effectiveOwnerPhiKey, @@ -2521,6 +2624,82 @@ React.useEffect(() => { zkVerifiedCached, ]); + const auditBundleText = useMemo(() => { + if (!auditBundlePayload) return ""; + return JSON.stringify(auditBundlePayload, null, 2); + }, [auditBundlePayload]); + + const proofBundleJson = useMemo(() => { + if (!auditBundlePayload) return ""; + return JSON.stringify(auditBundlePayload); + }, [auditBundlePayload]); + + const verifiedCardData = useMemo(() => { + if (result.status !== "ok" || !proofCapsule || !capsuleHash || stewardVerifiedPulse == null) return null; + const receiptValue = verificationReceipt ?? embeddedProof?.receipt ?? sharedReceipt?.receipt; + const receiptHashValue = receiptHash || embeddedProof?.receiptHash || sharedReceipt?.receiptHash; + const verificationSigValue = verificationSig ?? embeddedProof?.verificationSig ?? sharedReceipt?.verificationSig; + const valuationValue = valuationSnapshot && valuationHash ? { ...valuationSnapshot, valuationHash } : undefined; + const ownerPhiKeyValue = effectiveOwnerPhiKey ?? effectivePhiKey; + const verifierUrlValue = proofVerifierUrl || currentVerifyUrl; + return { + capsuleHash, + svgHash: svgHash || undefined, + pulse: proofCapsule.pulse, + verifiedAtPulse: stewardVerifiedPulse, + phikey: ownerPhiKeyValue && ownerPhiKeyValue !== "—" ? ownerPhiKeyValue : proofCapsule.phiKey, + kasOk: hasKASAuthSig ? sealKAS === "valid" : undefined, + g16Ok: sealZK === "valid", + verifierSlug: proofCapsule.verifierSlug, + verifierUrl: verifierUrlValue || undefined, + verifier: verificationSource, + verificationVersion, + bundleHash: bundleHash || undefined, + zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, + receipt: receiptValue ?? undefined, + receiptHash: receiptHashValue || undefined, + verificationSig: verificationSigValue ?? undefined, + sigilSvg: svgText.trim() ? svgText : undefined, + valuation: valuationValue, + proofBundleJson: proofBundleJson || undefined, + }; + }, [ + bundleHash, + capsuleHash, + currentVerifyUrl, + embeddedProof?.receipt, + embeddedProof?.receiptHash, + embeddedProof?.verificationSig, + effectiveOwnerPhiKey, + proofCapsule, + proofBundleJson, + proofVerifierUrl, + receiptHash, + result.status, + effectivePhiKey, + hasKASAuthSig, + sealKAS, + sealZK, + sharedReceipt?.receipt, + sharedReceipt?.receiptHash, + sharedReceipt?.verificationSig, + stewardVerifiedPulse, + svgHash, + svgText, + valuationHash, + valuationSnapshot, + verificationReceipt, + verificationSig, + verificationSource, + verificationVersion, + zkMeta?.zkPoseidonHash, + ]); + + const onDownloadVerifiedCard = useCallback(async () => { + if (!verifiedCardData) return; + await downloadVerifiedCardPng(verifiedCardData); + }, [verifiedCardData]); + const receiptJson = useMemo(() => { if (!proofCapsule) return ""; const receipt = { @@ -2555,12 +2734,12 @@ React.useEffect(() => { if (effectiveReceiveBundleHash) extended.receiveBundleHash = effectiveReceiveBundleHash; const shareUrlValue = embeddedProof?.transport?.shareUrl ?? embeddedProof?.shareUrl; if (shareUrlValue) extended.shareUrl = shareUrlValue; - if (effectiveOriginAuthorSig) extended.originAuthorSig = effectiveOriginAuthorSig; + if (provenanceAuthorSig) extended.originAuthorSig = provenanceAuthorSig; if (effectiveReceiveSig) extended.receiveSig = effectiveReceiveSig; if (effectiveReceivePulse != null) extended.receivePulse = effectiveReceivePulse; if (effectiveOwnerPhiKey) extended.ownerPhiKey = effectiveOwnerPhiKey; if (effectiveOwnerKeyDerivation) extended.ownerKeyDerivation = effectiveOwnerKeyDerivation; - if (embeddedProof?.authorSig && !effectiveOriginAuthorSig && effectiveReceiveMode !== "receive") { + if (embeddedProof?.authorSig) { extended.authorSig = embeddedProof.authorSig; } if (embeddedProof?.zkProof) extended.zkProof = embeddedProof.zkProof; @@ -2594,7 +2773,7 @@ React.useEffect(() => { embeddedProof?.authorSig, embeddedProof?.zkProof, embeddedProof?.zkPublicInputs, - effectiveOriginAuthorSig, + provenanceAuthorSig, effectiveOriginBundleHash, effectiveOwnerKeyDerivation, effectiveOwnerPhiKey, @@ -2632,7 +2811,8 @@ React.useEffect(() => { const onShareReceipt = useCallback(async () => { const url = shareReceiptUrl || proofVerifierUrl || currentVerifyUrl; const title = `Proof of Breath™ — ${shareStatus}`; - const text = `${shareStatus} • Pulse ${verifierPulse} • ΦKey ${sharePhiShort} • KAS ${shareKas} • G16 ${shareG16}`; + const kasSegment = shareKas ? ` • KAS ${shareKas}` : ""; + const text = `${shareStatus} • Pulse ${verifierPulse} • ΦKey ${sharePhiShort}${kasSegment} • G16 ${shareG16}`; if (navigator.share) { try { @@ -2719,13 +2899,21 @@ React.useEffect(() => {
- + {hasKASAuthSig ? ( + + ) : null} { > ✍ - @@ -2881,6 +3059,16 @@ React.useEffect(() => { e.currentTarget.value = ""; }} /> + { + handleFiles(e.currentTarget.files); + e.currentTarget.value = ""; + }} + />
{/* Control FIRST on mobile (CSS reorders) */} @@ -2900,6 +3088,87 @@ React.useEffect(() => { Φ ΦKey + +
@@ -2916,13 +3185,19 @@ React.useEffect(() => { setResult({ status: "idle" }); setNotice(""); }} - disabled={!svgText.trim()} + disabled={!svgText.trim() && !sharedReceipt} />
-
- - -
+ {hasKASOwnerSig ? ( +
+ + +
+ ) : ( +
+ +
+ )}
@@ -2946,7 +3221,7 @@ React.useEffect(() => {
@@ -3119,29 +3394,27 @@ React.useEffect(() => {
+ {result.status === "ok" && displayPhi != null ? ( +
+ openChartPopover("phi")} + ariaLabel="Open live chart for Φ value" + /> + openChartPopover("usd")} + ariaLabel="Open live chart for USD value" + /> +
+ ) : null} - - {result.status === "ok" && displayPhi != null ? ( -
- openChartPopover("phi")} - ariaLabel="Open live chart for Φ value" - /> - openChartPopover("usd")} - ariaLabel="Open live chart for USD value" - /> -
- ) : null} - -
-
-
- Audit JSON +
+
+
+ Audit JSON
@@ -3236,27 +3509,55 @@ React.useEffect(() => {
- - - - - - - + {hasKASOwnerSig ? ( + + ) : null} + {hasKASOwnerSig ? ( + + ) : null} + {!isReceiveGlyph && hasKASProvenanceSig ? ( + + ) : null} + {!isReceiveGlyph && hasKASProvenanceSig ? ( + + ) : null} + {hasKASReceiveSig && isReceiveGlyph ? ( + + ) : null} + {hasKASReceiveSig && isReceiveGlyph ? ( + + ) : null} + {hasKASAuthSig ? ( + + ) : null} + {hasKASAuthSig ? ( + + ) : null} + {hasKASAuthSig ? ( + + ) : null}
-
- -
+ {hasKASReceiveSig && isReceiveGlyph ? ( +
+ +
+ ) : null} - {effectiveReceiveSig ? ( + {hasKASReceiveSig && isReceiveGlyph && effectiveReceiveSig ? (
{
- + {hasKASAuthSig ? : null}
diff --git a/src/utils/pngChunks.ts b/src/utils/pngChunks.ts new file mode 100644 index 000000000..79dd81420 --- /dev/null +++ b/src/utils/pngChunks.ts @@ -0,0 +1,202 @@ +const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); +const textDecoder = new TextDecoder("utf-8"); +const textEncoder = new TextEncoder(); + +export type PngTextChunk = Readonly<{ + keyword: string; + text: string; +}>; + +function assertPngSignature(bytes: Uint8Array): void { + if (bytes.length < PNG_SIGNATURE.length) { + throw new Error("Invalid PNG: missing signature"); + } + for (let i = 0; i < PNG_SIGNATURE.length; i += 1) { + if (bytes[i] !== PNG_SIGNATURE[i]) { + throw new Error("Invalid PNG: bad signature"); + } + } +} + +function readUint32BE(bytes: Uint8Array, offset: number): number { + return ( + ((bytes[offset] ?? 0) << 24) | + ((bytes[offset + 1] ?? 0) << 16) | + ((bytes[offset + 2] ?? 0) << 8) | + (bytes[offset + 3] ?? 0) + ) >>> 0; +} + +function writeUint32BE(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +} + +function chunkType(bytes: Uint8Array, offset: number): string { + return String.fromCharCode( + bytes[offset] ?? 0, + bytes[offset + 1] ?? 0, + bytes[offset + 2] ?? 0, + bytes[offset + 3] ?? 0, + ); +} + +function indexOfNull(bytes: Uint8Array, start: number): number { + for (let i = start; i < bytes.length; i += 1) { + if (bytes[i] === 0) return i; + } + return -1; +} + +function parseITXtData(data: Uint8Array): PngTextChunk | null { + const keywordEnd = indexOfNull(data, 0); + if (keywordEnd <= 0) return null; + const keyword = textDecoder.decode(data.slice(0, keywordEnd)); + let cursor = keywordEnd + 1; + if (cursor + 2 > data.length) return null; + const compressionFlag = data[cursor] ?? 0; + const compressionMethod = data[cursor + 1] ?? 0; + cursor += 2; + if (compressionFlag !== 0 || compressionMethod !== 0) return null; + const langEnd = indexOfNull(data, cursor); + if (langEnd < 0) return null; + cursor = langEnd + 1; + const translatedEnd = indexOfNull(data, cursor); + if (translatedEnd < 0) return null; + cursor = translatedEnd + 1; + if (cursor > data.length) return null; + const text = textDecoder.decode(data.slice(cursor)); + return { keyword, text }; +} + +function buildITXtData(entry: PngTextChunk): Uint8Array { + const keywordBytes = textEncoder.encode(entry.keyword); + if (keywordBytes.length === 0 || keywordBytes.length > 79) { + throw new Error("Invalid iTXt keyword length"); + } + for (const byte of keywordBytes) { + if (byte === 0) throw new Error("Invalid iTXt keyword"); + } + const textBytes = textEncoder.encode(entry.text); + const out = new Uint8Array(keywordBytes.length + 5 + textBytes.length); + out.set(keywordBytes, 0); + let cursor = keywordBytes.length; + out[cursor] = 0; + out[cursor + 1] = 0; + out[cursor + 2] = 0; + out[cursor + 3] = 0; + out[cursor + 4] = 0; + cursor += 5; + out.set(textBytes, cursor); + return out; +} + +let crcTable: Uint32Array | null = null; + +function getCrcTable(): Uint32Array { + if (crcTable) return crcTable; + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + crcTable = table; + return table; +} + +function crc32(bytes: Uint8Array): number { + const table = getCrcTable(); + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i += 1) { + const byte = bytes[i] ?? 0; + crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function buildITXtChunk(entry: PngTextChunk): Uint8Array { + const typeBytes = new Uint8Array([105, 84, 88, 116]); + const data = buildITXtData(entry); + const length = data.length; + const chunk = new Uint8Array(12 + length); + writeUint32BE(chunk, 0, length); + chunk.set(typeBytes, 4); + chunk.set(data, 8); + const crcInput = new Uint8Array(typeBytes.length + data.length); + crcInput.set(typeBytes, 0); + crcInput.set(data, typeBytes.length); + const crc = crc32(crcInput); + writeUint32BE(chunk, 8 + length, crc); + return chunk; +} + +export function readPngTextChunks(pngBytes: Uint8Array): PngTextChunk[] { + assertPngSignature(pngBytes); + const chunks: PngTextChunk[] = []; + let offset = 8; + while (offset + 8 <= pngBytes.length) { + const length = readUint32BE(pngBytes, offset); + const type = chunkType(pngBytes, offset + 4); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > pngBytes.length) break; + if (type === "iTXt") { + const data = pngBytes.slice(dataStart, dataEnd); + const parsed = parseITXtData(data); + if (parsed) chunks.push(parsed); + } + offset = dataEnd + 4; + if (type === "IEND") break; + } + return chunks; +} + +export function readPngTextChunk(pngBytes: Uint8Array, keyword: string): string | null { + const chunks = readPngTextChunks(pngBytes); + for (const chunk of chunks) { + if (chunk.keyword === keyword) return chunk.text; + } + return null; +} + +export function insertPngTextChunks(pngBytes: Uint8Array, entries: PngTextChunk[]): Uint8Array { + if (entries.length === 0) return pngBytes; + assertPngSignature(pngBytes); + let offset = 8; + let iendOffset = -1; + while (offset + 8 <= pngBytes.length) { + const length = readUint32BE(pngBytes, offset); + const type = chunkType(pngBytes, offset + 4); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > pngBytes.length) break; + if (type === "IEND") { + iendOffset = offset; + break; + } + offset = dataEnd + 4; + } + if (iendOffset < 0) { + throw new Error("Invalid PNG: missing IEND"); + } + + const prefix = pngBytes.slice(0, iendOffset); + const suffix = pngBytes.slice(iendOffset); + const chunks = entries.map(buildITXtChunk); + const totalLength = prefix.length + suffix.length + chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(totalLength); + let cursor = 0; + out.set(prefix, cursor); + cursor += prefix.length; + for (const chunk of chunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + out.set(suffix, cursor); + return out; +} diff --git a/src/utils/verifySigil.ts b/src/utils/verifySigil.ts index cec5e60ef..727cf45c6 100644 --- a/src/utils/verifySigil.ts +++ b/src/utils/verifySigil.ts @@ -27,6 +27,7 @@ export type VerifyResult = embedded?: EmbeddedMeta; derivedPhiKey?: string; checks?: VerifyChecks; + embeddedRawPhiKey?: string | null; } | { status: "ok"; @@ -35,6 +36,7 @@ export type VerifyResult = derivedPhiKey: string; checks: VerifyChecks; verifiedAtPulse: number | null; + embeddedRawPhiKey: string | null; }; export function parseSlug(rawSlug: string): SlugInfo { @@ -59,6 +61,12 @@ function firstN(s: string, n: number): string { export async function verifySigilSvg(slug: SlugInfo, svgText: string, verifiedAtPulse?: number): Promise { try { const embedded = extractEmbeddedMetaFromSvg(svgText); + const embeddedRawPhiKey = + embedded.raw && typeof embedded.raw === "object" && embedded.raw !== null + ? typeof (embedded.raw as Record).phiKey === "string" + ? ((embedded.raw as Record).phiKey as string) + : null + : null; if (embedded.receiptHash) { await assertReceiptHashMatch(embedded.receipt, embedded.receiptHash); } @@ -69,6 +77,7 @@ export async function verifySigilSvg(slug: SlugInfo, svgText: string, verifiedAt message: "No kaiSignature found in the SVG metadata.", slug, embedded, + embeddedRawPhiKey, }; } @@ -109,6 +118,7 @@ export async function verifySigilSvg(slug: SlugInfo, svgText: string, verifiedAt embedded, derivedPhiKey, checks, + embeddedRawPhiKey, }; } @@ -126,6 +136,7 @@ export async function verifySigilSvg(slug: SlugInfo, svgText: string, verifiedAt }, derivedPhiKey, checks, + embeddedRawPhiKey, verifiedAtPulse: typeof verifiedAtPulse === "number" && Number.isFinite(verifiedAtPulse) ? verifiedAtPulse : null, }; diff --git a/src/utils/webauthnKAS.ts b/src/utils/webauthnKAS.ts index 183ebae5c..f4f9cceeb 100644 --- a/src/utils/webauthnKAS.ts +++ b/src/utils/webauthnKAS.ts @@ -11,6 +11,7 @@ import { decodeCbor } from "./cbor"; import { base64UrlDecode, base64UrlEncode, hexToBytes, sha256Bytes } from "./sha256"; +import { b64u, phiFromPublicKey } from "../components/VerifierStamper/crypto"; import type { KASAuthorSig } from "./authorSig"; export type StoredPasskey = { @@ -211,6 +212,94 @@ function readAuthDataFromAttestation(decoded: unknown): Uint8Array { throw new Error("Attestation authData is not a byte array."); } +async function exportSpkiB64uFromJwk(jwk: JsonWebKey): Promise { + const pubKey = await crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, true, []); + const spki = await crypto.subtle.exportKey("spki", pubKey); + return b64u.encode(new Uint8Array(spki)); +} + +function passkeyFromAttestation(credential: PublicKeyCredential): StoredPasskey { + const response = credential.response as AuthenticatorAttestationResponse; + const attestationObjectBytes = new Uint8Array(response.attestationObject); + const decoded = decodeCbor(attestationObjectBytes); + + const authData = readAuthDataFromAttestation(decoded); + const parsed = parseAuthData(authData); + + const coseKeyDecoded = decodeCbor(parsed.credentialPublicKey); + const pubKeyJwk = coseEc2ToJwk(coseKeyDecoded); + + return { + credId: base64UrlEncode(new Uint8Array(credential.rawId)), + pubKeyJwk, + }; +} + +export function storePasskey(phiKey: string, record: StoredPasskey): void { + saveStored(phiKey, record); +} + +export async function derivePhiKeyFromPubKeyJwk(pubKeyJwk: JsonWebKey): Promise { + const spkiB64u = await exportSpkiB64uFromJwk(pubKeyJwk); + return phiFromPublicKey(spkiB64u); +} + +export async function createPasskeyForPhiKey(args: { + phiKey: string; + rpName?: string; + rpId?: string; + residentKey?: "preferred" | "required"; + timeoutMs?: number; + excludeCredentials?: string[]; +}): Promise { + if (!isWebAuthnSupported()) { + throw new Error("WebAuthn is not available in this browser."); + } + + await tryRequestPersistentStorage(); + + const userIdFull = await sha256Bytes(`KAS-1|phiKey|${args.phiKey}`); + const userId = userIdFull.slice(0, 16); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + const excludeCredentials = args.excludeCredentials?.length + ? args.excludeCredentials.map((id) => ({ + id: bytesToArrayBuffer(base64UrlDecode(id)), + type: "public-key" as const, + })) + : undefined; + + const created = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { + name: args.rpName ?? "Kai-Voh", + ...(args.rpId ? { id: args.rpId } : { id: window.location.hostname }), + }, + user: { + id: userId, + name: args.phiKey, + displayName: args.phiKey, + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + authenticatorSelection: { + residentKey: args.residentKey ?? "preferred", + userVerification: "required", + }, + timeout: args.timeoutMs ?? 60_000, + attestation: "none", + ...(excludeCredentials ? { excludeCredentials } : {}), + }, + }); + + const credential = (created ?? null) as PublicKeyCredential | null; + if (!credential) { + throw new Error("Passkey creation was canceled or failed."); + } + + return passkeyFromAttestation(credential); +} + export async function ensurePasskey(phiKey: string): Promise { if (!isWebAuthnSupported()) { throw new Error("WebAuthn is not available in this browser."); @@ -256,22 +345,7 @@ export async function ensurePasskey(phiKey: string): Promise { throw new Error("Passkey creation was canceled or failed."); } - const response = credential.response as AuthenticatorAttestationResponse; - - const attestationObjectBytes = new Uint8Array(response.attestationObject); - const decoded = decodeCbor(attestationObjectBytes); - - const authData = readAuthDataFromAttestation(decoded); - const parsed = parseAuthData(authData); - - const coseKeyDecoded = decodeCbor(parsed.credentialPublicKey); - const pubKeyJwk = coseEc2ToJwk(coseKeyDecoded); - - const record: StoredPasskey = { - credId: base64UrlEncode(new Uint8Array(credential.rawId)), - pubKeyJwk, - }; - + const record = passkeyFromAttestation(credential); saveStored(phiKey, record); return record; } diff --git a/src/utils/webauthnOwner.ts b/src/utils/webauthnOwner.ts new file mode 100644 index 000000000..23f19d160 --- /dev/null +++ b/src/utils/webauthnOwner.ts @@ -0,0 +1,144 @@ +import { base64UrlDecode, base64UrlEncode, sha256Bytes } from "./sha256"; + +export type WebAuthnAssertionJSON = { + id: string; + rawId: string; + type: "public-key"; + response: { + authenticatorData: string; + clientDataJSON: string; + signature: string; + userHandle: string | null; + }; +}; + +type ClientData = { + type?: string; + challenge?: string; +}; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buf).set(bytes); + return buf; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +function derToRawSignature(signature: Uint8Array, size: number): Uint8Array | null { + if (signature.length < 8 || signature[0] !== 0x30) return null; + const totalLength = signature[1]; + if (totalLength + 2 !== signature.length) return null; + let offset = 2; + if (signature[offset] !== 0x02) return null; + const rLen = signature[offset + 1]; + offset += 2; + const r = signature.slice(offset, offset + rLen); + offset += rLen; + if (signature[offset] !== 0x02) return null; + const sLen = signature[offset + 1]; + offset += 2; + const s = signature.slice(offset, offset + sLen); + + const rTrim = r[0] === 0x00 ? r.slice(1) : r; + const sTrim = s[0] === 0x00 ? s.slice(1) : s; + if (rTrim.length > size || sTrim.length > size) return null; + + const raw = new Uint8Array(size * 2); + raw.set(rTrim, size - rTrim.length); + raw.set(sTrim, size * 2 - sTrim.length); + return raw; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +async function importP256Jwk(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]); +} + +function parseClientData(clientDataJSON: string): ClientData | null { + try { + const parsed = JSON.parse(clientDataJSON) as ClientData; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +export function assertionToJson(credential: PublicKeyCredential): WebAuthnAssertionJSON { + const response = credential.response as AuthenticatorAssertionResponse; + const rawIdBytes = new Uint8Array(credential.rawId); + const userHandleBytes = response.userHandle ? new Uint8Array(response.userHandle) : null; + + return { + id: credential.id, + rawId: base64UrlEncode(rawIdBytes), + type: "public-key", + response: { + authenticatorData: base64UrlEncode(new Uint8Array(response.authenticatorData)), + clientDataJSON: base64UrlEncode(new Uint8Array(response.clientDataJSON)), + signature: base64UrlEncode(new Uint8Array(response.signature)), + userHandle: userHandleBytes ? base64UrlEncode(userHandleBytes) : null, + }, + }; +} + +export async function verifyOwnerWebAuthnAssertion(args: { + assertion: WebAuthnAssertionJSON; + expectedChallenge: Uint8Array; + pubKeyJwk: JsonWebKey; + expectedCredId: string; +}): Promise { + try { + if (args.assertion.type !== "public-key") return false; + if (args.expectedCredId) { + const expectedBytes = base64UrlDecode(args.expectedCredId); + const actualBytes = base64UrlDecode(args.assertion.rawId); + if (!bytesEqual(expectedBytes, actualBytes)) return false; + } + + const expectedChallengeB64 = base64UrlEncode(args.expectedChallenge); + const clientDataBytes = base64UrlDecode(args.assertion.response.clientDataJSON); + const clientDataText = new TextDecoder().decode(clientDataBytes); + const clientData = parseClientData(clientDataText); + if (!clientData) return false; + if (clientData.type !== "webauthn.get") return false; + if (clientData.challenge !== expectedChallengeB64) return false; + + const authenticatorData = base64UrlDecode(args.assertion.response.authenticatorData); + const clientDataHash = await sha256Bytes(clientDataBytes); + const signedPayload = concatBytes(authenticatorData, clientDataHash); + const signatureBytes = base64UrlDecode(args.assertion.response.signature); + + const pubKey = await importP256Jwk(args.pubKeyJwk); + const verified = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + pubKey, + toArrayBuffer(signatureBytes), + toArrayBuffer(signedPayload) + ); + if (verified) return true; + + const rawSig = derToRawSignature(signatureBytes, 32); + if (!rawSig) return false; + return crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + pubKey, + toArrayBuffer(rawSig), + toArrayBuffer(signedPayload) + ); + } catch { + return false; + } +} diff --git a/src/utils/webauthnReceive.ts b/src/utils/webauthnReceive.ts index 346a67736..deb7c963e 100644 --- a/src/utils/webauthnReceive.ts +++ b/src/utils/webauthnReceive.ts @@ -42,6 +42,8 @@ type AuthData = { credentialPublicKey: Uint8Array; }; +type NavigatorWithCredentials = Navigator & { credentials: CredentialsContainer }; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -67,6 +69,15 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return buf; } +function getNavigatorCredentials(): CredentialsContainer | null { + if (typeof navigator === "undefined") return null; + if ("credentials" in navigator) { + const nav = navigator as NavigatorWithCredentials; + return nav.credentials ?? null; + } + return null; +} + export function isReceiveSig(value: unknown): value is ReceiveSig { if (!isRecord(value)) return false; return ( @@ -99,7 +110,8 @@ export async function getWebAuthnAssertionJson(args: { allowCredIds?: string[]; preferInternal?: boolean; }): Promise { - if (typeof navigator === "undefined" || !navigator.credentials?.get) { + const credentials = getNavigatorCredentials(); + if (!credentials?.get) { throw new Error("WebAuthn is not available in this environment."); } @@ -119,7 +131,7 @@ export async function getWebAuthnAssertionJson(args: { })) : undefined; - const assertion = (await navigator.credentials.get({ + const assertion = (await credentials.get({ publicKey: { challenge: toArrayBuffer(args.challenge), allowCredentials, @@ -333,7 +345,8 @@ function coseEc2ToJwk(coseKey: unknown): JsonWebKey { export async function ensureReceiverPasskey(): Promise { const existing = loadStoredReceiverPasskey(); if (existing) return existing; - if (typeof navigator === "undefined" || !navigator.credentials?.create) { + const credentials = getNavigatorCredentials(); + if (!credentials?.create) { throw new Error("WebAuthn is not available in this browser."); } @@ -341,7 +354,7 @@ export async function ensureReceiverPasskey(): Promise { const userId = userIdFull.slice(0, 16); const challenge = crypto.getRandomValues(new Uint8Array(32)); - const credential = (await navigator.credentials.create({ + const credential = (await credentials.create({ publicKey: { challenge, rp: { diff --git a/src/version.ts b/src/version.ts index ebb6f9cf0..bf64456e3 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,7 +1,7 @@ // src/version.ts // Shared PWA version constants so the app shell, SW registration, and UI stay in sync. -export const BASE_APP_VERSION = "42.0.0"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.2.0"; // Canonical offline/PWA version export const SW_VERSION_EVENT = "kairos:sw-version"; export const DEFAULT_APP_VERSION = BASE_APP_VERSION; // Keep in sync with public/sw.js const ENV_APP_VERSION = diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 4db48f281..3987964ba 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/sovereignsolar.ts","./src/entry-client.tsx","./src/entry-server-exports.ts","./src/entry-server.tsx","./src/main.tsx","./src/types.ts","./src/version.ts","./src/components/daydetailmodal.tsx","./src/components/errorboundary.tsx","./src/components/eternalklock.tsx","./src/components/exhalenote.tsx","./src/components/feedcard.tsx","./src/components/glyphimportmodal.tsx","./src/components/homepricechartcard.tsx","./src/components/homepricetickerfallback.tsx","./src/components/inhaleuploadicon.tsx","./src/components/kaiklock.canon.ts","./src/components/kaiklock.tsx","./src/components/kaiklockhomeface.tsx","./src/components/kaipricechart.tsx","./src/components/kaisigil.tsx","./src/components/kaisplashscreen.tsx","./src/components/largeglyphminter.tsx","./src/components/largeglyphviewer.tsx","./src/components/monthkalendarmodal.tsx","./src/components/notemodal.tsx","./src/components/phistreampopover.tsx","./src/components/resultcard.tsx","./src/components/sealmomentmodal.tsx","./src/components/sealmomentmodaltransfer.tsx","./src/components/sendsigilmodal.tsx","./src/components/sigilconflictbanner copy.tsx","./src/components/sigilconflictbanner.tsx","./src/components/sigilexplorer.tsx","./src/components/sigilglyphbutton.tsx","./src/components/sigilmodal.tsx","./src/components/sigilmomentrow.tsx","./src/components/sigilpublisherpanel.tsx","./src/components/solaranchoreddial.tsx","./src/components/sovereigndeclarations.tsx","./src/components/stargateviewer.tsx","./src/components/valuationmodal.tsx","./src/components/valuehistorymodal.tsx","./src/components/verifierform.tsx","./src/components/weekkalendarmodal.tsx","./src/components/kairealms/gameportal.tsx","./src/components/kairealms/glyphutils.ts","./src/components/kairealms/inventory.tsx","./src/components/kairealms/kaikasino.tsx","./src/components/kairealms/kaipulseengine.ts","./src/components/kairealms/missionrunner.tsx","./src/components/kairealms/realmview.tsx","./src/components/kairealms/sigilavatar.tsx","./src/components/kairealms/worldstate.ts","./src/components/kairealms/constants.ts","./src/components/kairealms/index.tsx","./src/components/kairealms/styles.ts","./src/components/kairealms/types.ts","./src/components/kairealms/usegamesession.ts","./src/components/kairealms/kaimaze/kaimaze.tsx","./src/components/kairealms/kaimaze/index.ts","./src/components/kairealms/kaimaze/engine/ai.ts","./src/components/kairealms/kaimaze/engine/constants.ts","./src/components/kairealms/kaimaze/engine/engine.ts","./src/components/kairealms/kaimaze/engine/input.ts","./src/components/kairealms/kaimaze/engine/map.ts","./src/components/kairealms/kaimaze/engine/physics.ts","./src/components/kairealms/kaimaze/engine/types.ts","./src/components/kairealms/lib/gamefocus.ts","./src/components/kaisigil/art.tsx","./src/components/kaisigil/defs.tsx","./src/components/kaisigil/metadata.tsx","./src/components/kaisigil/zkglyph.tsx","./src/components/kaisigil/constants.ts","./src/components/kaisigil/crypto.ts","./src/components/kaisigil/embed.ts","./src/components/kaisigil/exporters.ts","./src/components/kaisigil/freq.ts","./src/components/kaisigil/helpers.ts","./src/components/kaisigil/hooks.ts","./src/components/kaisigil/identity.ts","./src/components/kaisigil/step.ts","./src/components/kaisigil/types.ts","./src/components/kaisigil/utils.ts","./src/components/kaisigil/valuationbridge.ts","./src/components/kaivoh/breathsealer.tsx","./src/components/kaivoh/kaiverifierlink.tsx","./src/components/kaivoh/kaivoh.tsx","./src/components/kaivoh/kaivohapp.tsx","./src/components/kaivoh/kaivohboundary.tsx","./src/components/kaivoh/kaivohmodal.tsx","./src/components/kaivoh/multisharedispatcher.tsx","./src/components/kaivoh/phikeyresolver.ts","./src/components/kaivoh/postbody.tsx","./src/components/kaivoh/postcomposer.tsx","./src/components/kaivoh/sessionmanager.tsx","./src/components/kaivoh/sigilauth.base.ts","./src/components/kaivoh/sigilauthcontext.tsx","./src/components/kaivoh/sigilauthprovider.tsx","./src/components/kaivoh/sigillogin.tsx","./src/components/kaivoh/sigilmemorybuilder.ts","./src/components/kaivoh/signatureembedder.ts","./src/components/kaivoh/socialconnector.shared.ts","./src/components/kaivoh/socialconnector.tsx","./src/components/kaivoh/storyrecorder.tsx","./src/components/kaivoh/verifierframe.tsx","./src/components/kaivoh/encodetoken.worker.ts","./src/components/kaivoh/kaivohencode.worker.ts","./src/components/kaivoh/usesigilauth.ts","./src/components/kaivoh/verifierproof.ts","./src/components/sigilexplorer/pulsehoneycombmodal.tsx","./src/components/sigilexplorer/sigilexplorer.tsx","./src/components/sigilexplorer/sigilhoneycombexplorer.tsx","./src/components/sigilexplorer/apiclient.ts","./src/components/sigilexplorer/chakra.ts","./src/components/sigilexplorer/format.ts","./src/components/sigilexplorer/index.ts","./src/components/sigilexplorer/inhalequeue.ts","./src/components/sigilexplorer/kaicadence.ts","./src/components/sigilexplorer/registrystore.ts","./src/components/sigilexplorer/remotepull.ts","./src/components/sigilexplorer/transfers.ts","./src/components/sigilexplorer/treebuilder.ts","./src/components/sigilexplorer/treetypes.ts","./src/components/sigilexplorer/types.ts","./src/components/sigilexplorer/url.ts","./src/components/sigilexplorer/urlhealth.ts","./src/components/sigilexplorer/witness.ts","./src/components/sigilexplorer/tree/buildforest.ts","./src/components/sigilexplorer/tree/types.ts","./src/components/verifierstamper/sendphiamountfield.tsx","./src/components/verifierstamper/sigilmomentrow.tsx","./src/components/verifierstamper/verifierstamper.tsx","./src/components/verifierstamper/constants.ts","./src/components/verifierstamper/crypto.ts","./src/components/verifierstamper/files.ts","./src/components/verifierstamper/keys.ts","./src/components/verifierstamper/merkle.ts","./src/components/verifierstamper/segments.ts","./src/components/verifierstamper/sigilutils.ts","./src/components/verifierstamper/styles.ts","./src/components/verifierstamper/svg.ts","./src/components/verifierstamper/types.ts","./src/components/verifierstamper/ui.tsx","./src/components/verifierstamper/verifyhistorical.ts","./src/components/verifierstamper/verifysovereignoffline.ts","./src/components/verifierstamper/window.ts","./src/components/verifierstamper/zk.ts","./src/components/verifierstamper/hooks/useautoshrink.ts","./src/components/verifierstamper/hooks/userollingchartseries.ts","./src/components/exhale-note/banknotesvg.ts","./src/components/exhale-note/bridge.ts","./src/components/exhale-note/constants.ts","./src/components/exhale-note/css.ts","./src/components/exhale-note/cutmarks.ts","./src/components/exhale-note/dom.ts","./src/components/exhale-note/exporters.ts","./src/components/exhale-note/format.ts","./src/components/exhale-note/hash.ts","./src/components/exhale-note/printer.ts","./src/components/exhale-note/proofpages.ts","./src/components/exhale-note/qr.ts","./src/components/exhale-note/sanitize.ts","./src/components/exhale-note/sigilembed.ts","./src/components/exhale-note/svgtopng.ts","./src/components/exhale-note/titles.ts","./src/components/exhale-note/types.ts","./src/components/session/sessioncontext.ts","./src/components/session/sessionprovider.tsx","./src/components/session/sigilsessioncontext.ts","./src/components/session/sigilsessionprovider.tsx","./src/components/session/sigilsessiontypes.ts","./src/components/session/sessionstorage.ts","./src/components/session/sessiontypes.ts","./src/components/session/usesession.ts","./src/components/session/usesigilsession.ts","./src/components/shortner/shortredirect.tsx","./src/components/shortner/shorturltool.tsx","./src/components/shortner/index.tsx","./src/components/shortner/kaiphishort.ts","./src/components/sigil/kaiqr.tsx","./src/components/sigil/mobilesafefileinput.tsx","./src/components/sigil/ownershippanel.tsx","./src/components/sigil/ownershipverifier.tsx","./src/components/sigil/ownershipverifymodal.tsx","./src/components/sigil/phidepositpanel.tsx","./src/components/sigil/provenancelist.tsx","./src/components/sigil/sigilcta.tsx","./src/components/sigil/sigilframe.tsx","./src/components/sigil/sigilheader.tsx","./src/components/sigil/sigilmetapanel.tsx","./src/components/sigil/sovereigncontrols.tsx","./src/components/sigil/stargateoverlay.tsx","./src/components/sigil/upgradesigilmodal.tsx","./src/components/sigil/openownershipverifymodal.tsx","./src/components/sigil/theme.tsx","./src/components/valuation/donorseditor.tsx","./src/components/valuation/mintcompositemodal.tsx","./src/components/valuation/asset.ts","./src/components/valuation/constants.ts","./src/components/valuation/display.ts","./src/components/valuation/drivers.ts","./src/components/valuation/globals.d.ts","./src/components/valuation/hooks.ts","./src/components/valuation/math.ts","./src/components/valuation/platform.ts","./src/components/valuation/rarity.ts","./src/components/valuation/series.ts","./src/components/valuation/chart/livechart.tsx","./src/components/valuation/chart/valuationcard.tsx","./src/components/valuation/chart/valuedonut.tsx","./src/components/valuation/types/window.d.ts","./src/components/verifier/sendphiamountfield.tsx","./src/components/verifier/verifiererrorboundary.tsx","./src/components/verifier/hooks/usemetasignals.ts","./src/components/verifier/types/local.ts","./src/components/verifier/types/window.d.ts","./src/components/verifier/ui/jsontree.tsx","./src/components/verifier/ui/statuschips.tsx","./src/components/verifier/utils/base64.ts","./src/components/verifier/utils/childexpiry.ts","./src/components/verifier/utils/decimal.ts","./src/components/verifier/utils/dialog.ts","./src/components/verifier/utils/log.ts","./src/components/verifier/utils/metadataset.ts","./src/components/verifier/utils/modal.ts","./src/components/verifier/utils/notepayload.ts","./src/components/verifier/utils/rotationbus.ts","./src/components/verifier/utils/saferead.ts","./src/components/verifier/utils/sigilglobal.ts","./src/components/verifier/utils/sigilmemoryvault.ts","./src/components/verifier/utils/statemachine.ts","./src/components/verifier/utils/urlpayload.ts","./src/constants/sigilexplorer.ts","./src/glyph/glyphmodal.tsx","./src/glyph/glyphengine.ts","./src/glyph/glyphutils.ts","./src/glyph/types.ts","./src/glyph/useglyphlogic.ts","./src/hooks/useauthorityproof.ts","./src/hooks/usebodyscrolllock.ts","./src/hooks/usedisablezoom.ts","./src/hooks/usefastpress.ts","./src/hooks/usekaiparitypricepoints.ts","./src/hooks/usekaiticker.ts","./src/hooks/useperfmode.ts","./src/hooks/useresponsivesigilsize.ts","./src/hooks/userotationbus.ts","./src/hooks/usevaluehistory.ts","./src/hooks/usevisualviewportsize.ts","./src/kai/kainow.ts","./src/lib/download.ts","./src/lib/hash.ts","./src/lib/mobilepopoverfix.ts","./src/lib/qr.ts","./src/lib/sigilregistryclient.ts","./src/lib/ledger/log.ts","./src/lib/ledger/merkle.ts","./src/lib/ledger/types.ts","./src/lib/sigil/breathproof.ts","./src/lib/sigil/canonicalize.ts","./src/lib/sigil/codec.ts","./src/lib/sigil/embed.ts","./src/lib/sigil/extract.ts","./src/lib/sigil/hash.ts","./src/lib/sigil/recover.ts","./src/lib/sigil/signature.ts","./src/lib/sigil/__tests__/canonicalize.test.ts","./src/lib/sigil/__tests__/hash.test.ts","./src/lib/sync/dht.ts","./src/lib/sync/ipfsadapter.ts","./src/lib/sync/nopadapter.ts","./src/og/buildverifiedcardsvg.ts","./src/og/cache.ts","./src/og/capsulestore.ts","./src/og/downloadverifiedcard.ts","./src/og/rendernotfoundog.ts","./src/og/renderverifiedog.ts","./src/og/sigilembed.ts","./src/og/svgtopng.ts","./src/og/types.ts","./src/pages/pshort.tsx","./src/pages/sigilfeedpage.tsx","./src/pages/verifyembedpage.tsx","./src/pages/verifypage.tsx","./src/pages/verifysigil.tsx","./src/pages/sigilpage/sigilpage.tsx","./src/pages/sigilpage/constants.ts","./src/pages/sigilpage/debits.ts","./src/pages/sigilpage/descendants.ts","./src/pages/sigilpage/exportzip.ts","./src/pages/sigilpage/linkshare.ts","./src/pages/sigilpage/modalutils.ts","./src/pages/sigilpage/momentkeys.ts","./src/pages/sigilpage/ogimage.ts","./src/pages/sigilpage/posterexport.tsx","./src/pages/sigilpage/registry.ts","./src/pages/sigilpage/registrysign.ts","./src/pages/sigilpage/rotation.ts","./src/pages/sigilpage/rotationbus.ts","./src/pages/sigilpage/sendlock.ts","./src/pages/sigilpage/styleinject.ts","./src/pages/sigilpage/svgops.ts","./src/pages/sigilpage/types.ts","./src/pages/sigilpage/usesigilsend.ts","./src/pages/sigilpage/usevaluation.ts","./src/pages/sigilpage/utils.ts","./src/pages/sigilpage/verifiercanon.public.ts","./src/pages/sigilpage/verifiercanon.ts","./src/pages/sigilstream/sigilstreamroot.tsx","./src/pages/sigilstream/index.ts","./src/pages/sigilstream/attachments/embeds.tsx","./src/pages/sigilstream/attachments/files.ts","./src/pages/sigilstream/attachments/gallery.tsx","./src/pages/sigilstream/attachments/types.ts","./src/pages/sigilstream/composer/composer.tsx","./src/pages/sigilstream/composer/linkhelpers.ts","./src/pages/sigilstream/core/alias.ts","./src/pages/sigilstream/core/kai_time.ts","./src/pages/sigilstream/core/phistreamautoadd.ts","./src/pages/sigilstream/core/ticker.ts","./src/pages/sigilstream/core/types.ts","./src/pages/sigilstream/core/urldisplay.ts","./src/pages/sigilstream/core/utils.ts","./src/pages/sigilstream/data/memorystreamv2.ts","./src/pages/sigilstream/data/seed.ts","./src/pages/sigilstream/data/storage.ts","./src/pages/sigilstream/data/toast/toasts.tsx","./src/pages/sigilstream/data/toast/toast.ts","./src/pages/sigilstream/identity/identitybar.tsx","./src/pages/sigilstream/identity/sigilactionurl.tsx","./src/pages/sigilstream/inhaler/inhalesection.tsx","./src/pages/sigilstream/list/streamlist.tsx","./src/pages/sigilstream/payload/payloadbanner.tsx","./src/pages/sigilstream/payload/types.ts","./src/pages/sigilstream/payload/usepayload.ts","./src/pages/sigilstream/status/kaistatus.tsx","./src/pages/sigilstream/status/proofbadge.tsx","./src/perf/perfprofiler.tsx","./src/perf/perfdebug.tsx","./src/router/approuter.tsx","./src/session/sigilsession.tsx","./src/session/sigilsessioncontext.ts","./src/session/sigilsessiontypes.ts","./src/session/usesigilsession.ts","./src/ssr/ssrsnapshotcontext.tsx","./src/ssr/cache.ts","./src/ssr/loaders.ts","./src/ssr/safejson.ts","./src/ssr/serverexports.ts","./src/ssr/snapshotclient.ts","./src/ssr/snapshottypes.ts","./src/types/crypto-shims.d.ts","./src/types/global.d.ts","./src/types/jsqr.d.ts","./src/types/klocktypes.ts","./src/types/pako.d.ts","./src/types/react-router-dom-server.d.ts","./src/types/sigil-global.d.ts","./src/types/sigil.ts","./src/types/snarkjs-shim.d.ts","./src/types/snarkjs.d.ts","./src/types/usernameclaim.ts","./src/types/zkp-prover.d.ts","./src/utils/authorsig.ts","./src/utils/base64url.ts","./src/utils/cbor.ts","./src/utils/constants.ts","./src/utils/cryptoledger.ts","./src/utils/derivedglyph.ts","./src/utils/domhead.ts","./src/utils/extractkaimetadata.ts","./src/utils/feedpayload.ts","./src/utils/globaltokenregistry.ts","./src/utils/hash.ts","./src/utils/jcs.ts","./src/utils/kai.ts","./src/utils/kaimath.ts","./src/utils/kaitimedisplay.ts","./src/utils/kai_cadence.ts","./src/utils/kai_pulse.ts","./src/utils/kai_turah.ts","./src/utils/kairosmath.ts","./src/utils/klock_adapters.ts","./src/utils/kopyfeedback.ts","./src/utils/lahmahtor.ts","./src/utils/largeasset.ts","./src/utils/ownerphikey.ts","./src/utils/payload.ts","./src/utils/phi-issuance.ts","./src/utils/phi-precision.ts","./src/utils/platform.ts","./src/utils/poseidon.ts","./src/utils/postseal.ts","./src/utils/provenance.ts","./src/utils/qrexport.ts","./src/utils/receivebundle.ts","./src/utils/reloaddetective.ts","./src/utils/sanitizehtml.ts","./src/utils/sendledger.ts","./src/utils/sendlock.ts","./src/utils/sha256.ts","./src/utils/shareurl.ts","./src/utils/shortener.ts","./src/utils/sigilauthextract.ts","./src/utils/sigilcapsule.ts","./src/utils/sigildecode.ts","./src/utils/sigilexplorersync.ts","./src/utils/sigilmetadata.ts","./src/utils/sigilregistry.ts","./src/utils/sigiltransferregistry.ts","./src/utils/sigilurl.ts","./src/utils/solarsync.ts","./src/utils/streamlink.ts","./src/utils/svgmeta.ts","./src/utils/svgproof.ts","./src/utils/transferpackage.ts","./src/utils/urlshort.ts","./src/utils/useclientready.ts","./src/utils/useportaltarget.ts","./src/utils/usesigilpayload copy.ts","./src/utils/usesigilpayload.ts","./src/utils/usesovereignsolarclock.ts","./src/utils/usernameclaim.ts","./src/utils/usernameclaimregistry.ts","./src/utils/valuation.ts","./src/utils/valuationsnapshot.ts","./src/utils/verificationcache.ts","./src/utils/verificationreceipt.ts","./src/utils/verificationversion.ts","./src/utils/verifysigil.ts","./src/utils/webauthnkas.ts","./src/utils/webauthnreceive.ts","./src/utils/zkproof.ts","./src/verifier/canonical.ts","./src/verifier/validator.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/sovereignsolar.ts","./src/entry-client.tsx","./src/entry-server-exports.ts","./src/entry-server.tsx","./src/main.tsx","./src/types.ts","./src/version.ts","./src/components/daydetailmodal.tsx","./src/components/errorboundary.tsx","./src/components/eternalklock.tsx","./src/components/exhalenote.tsx","./src/components/feedcard.tsx","./src/components/glyphimportmodal.tsx","./src/components/homepricechartcard.tsx","./src/components/homepricetickerfallback.tsx","./src/components/inhaleuploadicon.tsx","./src/components/kaiklock.canon.ts","./src/components/kaiklock.tsx","./src/components/kaiklockhomeface.tsx","./src/components/kaipricechart.tsx","./src/components/kaisigil.tsx","./src/components/kaisplashscreen.tsx","./src/components/largeglyphminter.tsx","./src/components/largeglyphviewer.tsx","./src/components/monthkalendarmodal.tsx","./src/components/notemodal.tsx","./src/components/phistreampopover.tsx","./src/components/resultcard.tsx","./src/components/sealmomentmodal.tsx","./src/components/sealmomentmodaltransfer.tsx","./src/components/sendsigilmodal.tsx","./src/components/sigilconflictbanner copy.tsx","./src/components/sigilconflictbanner.tsx","./src/components/sigilexplorer.tsx","./src/components/sigilglyphbutton.tsx","./src/components/sigilmodal.tsx","./src/components/sigilmomentrow.tsx","./src/components/sigilpublisherpanel.tsx","./src/components/solaranchoreddial.tsx","./src/components/sovereigndeclarations.tsx","./src/components/stargateviewer.tsx","./src/components/valuationmodal.tsx","./src/components/valuehistorymodal.tsx","./src/components/verifierform.tsx","./src/components/weekkalendarmodal.tsx","./src/components/kairealms/gameportal.tsx","./src/components/kairealms/glyphutils.ts","./src/components/kairealms/inventory.tsx","./src/components/kairealms/kaikasino.tsx","./src/components/kairealms/kaipulseengine.ts","./src/components/kairealms/missionrunner.tsx","./src/components/kairealms/realmview.tsx","./src/components/kairealms/sigilavatar.tsx","./src/components/kairealms/worldstate.ts","./src/components/kairealms/constants.ts","./src/components/kairealms/index.tsx","./src/components/kairealms/styles.ts","./src/components/kairealms/types.ts","./src/components/kairealms/usegamesession.ts","./src/components/kairealms/kaimaze/kaimaze.tsx","./src/components/kairealms/kaimaze/index.ts","./src/components/kairealms/kaimaze/engine/ai.ts","./src/components/kairealms/kaimaze/engine/constants.ts","./src/components/kairealms/kaimaze/engine/engine.ts","./src/components/kairealms/kaimaze/engine/input.ts","./src/components/kairealms/kaimaze/engine/map.ts","./src/components/kairealms/kaimaze/engine/physics.ts","./src/components/kairealms/kaimaze/engine/types.ts","./src/components/kairealms/lib/gamefocus.ts","./src/components/kaisigil/art.tsx","./src/components/kaisigil/defs.tsx","./src/components/kaisigil/metadata.tsx","./src/components/kaisigil/zkglyph.tsx","./src/components/kaisigil/constants.ts","./src/components/kaisigil/crypto.ts","./src/components/kaisigil/embed.ts","./src/components/kaisigil/exporters.ts","./src/components/kaisigil/freq.ts","./src/components/kaisigil/helpers.ts","./src/components/kaisigil/hooks.ts","./src/components/kaisigil/identity.ts","./src/components/kaisigil/step.ts","./src/components/kaisigil/types.ts","./src/components/kaisigil/utils.ts","./src/components/kaisigil/valuationbridge.ts","./src/components/kaivoh/breathsealer.tsx","./src/components/kaivoh/kaiverifierlink.tsx","./src/components/kaivoh/kaivoh.tsx","./src/components/kaivoh/kaivohapp.tsx","./src/components/kaivoh/kaivohboundary.tsx","./src/components/kaivoh/kaivohmodal.tsx","./src/components/kaivoh/multisharedispatcher.tsx","./src/components/kaivoh/phikeyresolver.ts","./src/components/kaivoh/postbody.tsx","./src/components/kaivoh/postcomposer.tsx","./src/components/kaivoh/sessionmanager.tsx","./src/components/kaivoh/sigilauth.base.ts","./src/components/kaivoh/sigilauthcontext.tsx","./src/components/kaivoh/sigilauthprovider.tsx","./src/components/kaivoh/sigillogin.tsx","./src/components/kaivoh/sigilmemorybuilder.ts","./src/components/kaivoh/signatureembedder.ts","./src/components/kaivoh/socialconnector.shared.ts","./src/components/kaivoh/socialconnector.tsx","./src/components/kaivoh/storyrecorder.tsx","./src/components/kaivoh/verifierframe.tsx","./src/components/kaivoh/encodetoken.worker.ts","./src/components/kaivoh/kaivohencode.worker.ts","./src/components/kaivoh/usesigilauth.ts","./src/components/kaivoh/verifierproof.ts","./src/components/sigilexplorer/pulsehoneycombmodal.tsx","./src/components/sigilexplorer/sigilexplorer.tsx","./src/components/sigilexplorer/sigilhoneycombexplorer.tsx","./src/components/sigilexplorer/apiclient.ts","./src/components/sigilexplorer/chakra.ts","./src/components/sigilexplorer/format.ts","./src/components/sigilexplorer/index.ts","./src/components/sigilexplorer/inhalequeue.ts","./src/components/sigilexplorer/kaicadence.ts","./src/components/sigilexplorer/registrystore.ts","./src/components/sigilexplorer/remotepull.ts","./src/components/sigilexplorer/transfers.ts","./src/components/sigilexplorer/treebuilder.ts","./src/components/sigilexplorer/treetypes.ts","./src/components/sigilexplorer/types.ts","./src/components/sigilexplorer/url.ts","./src/components/sigilexplorer/urlhealth.ts","./src/components/sigilexplorer/witness.ts","./src/components/sigilexplorer/tree/buildforest.ts","./src/components/sigilexplorer/tree/types.ts","./src/components/verifierstamper/sendphiamountfield.tsx","./src/components/verifierstamper/sigilmomentrow.tsx","./src/components/verifierstamper/verifierstamper.tsx","./src/components/verifierstamper/constants.ts","./src/components/verifierstamper/crypto.ts","./src/components/verifierstamper/files.ts","./src/components/verifierstamper/keys.ts","./src/components/verifierstamper/merkle.ts","./src/components/verifierstamper/segments.ts","./src/components/verifierstamper/sigilutils.ts","./src/components/verifierstamper/styles.ts","./src/components/verifierstamper/svg.ts","./src/components/verifierstamper/types.ts","./src/components/verifierstamper/ui.tsx","./src/components/verifierstamper/verifyhistorical.ts","./src/components/verifierstamper/verifysovereignoffline.ts","./src/components/verifierstamper/window.ts","./src/components/verifierstamper/zk.ts","./src/components/verifierstamper/hooks/useautoshrink.ts","./src/components/verifierstamper/hooks/userollingchartseries.ts","./src/components/exhale-note/banknotesvg.ts","./src/components/exhale-note/bridge.ts","./src/components/exhale-note/constants.ts","./src/components/exhale-note/css.ts","./src/components/exhale-note/cutmarks.ts","./src/components/exhale-note/dom.ts","./src/components/exhale-note/exporters.ts","./src/components/exhale-note/format.ts","./src/components/exhale-note/hash.ts","./src/components/exhale-note/printer.ts","./src/components/exhale-note/proofpages.ts","./src/components/exhale-note/qr.ts","./src/components/exhale-note/sanitize.ts","./src/components/exhale-note/sigilembed.ts","./src/components/exhale-note/svgtopng.ts","./src/components/exhale-note/titles.ts","./src/components/exhale-note/types.ts","./src/components/session/sessioncontext.ts","./src/components/session/sessionprovider.tsx","./src/components/session/sigilsessioncontext.ts","./src/components/session/sigilsessionprovider.tsx","./src/components/session/sigilsessiontypes.ts","./src/components/session/sessionstorage.ts","./src/components/session/sessiontypes.ts","./src/components/session/usesession.ts","./src/components/session/usesigilsession.ts","./src/components/shortner/shortredirect.tsx","./src/components/shortner/shorturltool.tsx","./src/components/shortner/index.tsx","./src/components/shortner/kaiphishort.ts","./src/components/sigil/kaiqr.tsx","./src/components/sigil/mobilesafefileinput.tsx","./src/components/sigil/ownershippanel.tsx","./src/components/sigil/ownershipverifier.tsx","./src/components/sigil/ownershipverifymodal.tsx","./src/components/sigil/phidepositpanel.tsx","./src/components/sigil/provenancelist.tsx","./src/components/sigil/sigilcta.tsx","./src/components/sigil/sigilframe.tsx","./src/components/sigil/sigilheader.tsx","./src/components/sigil/sigilmetapanel.tsx","./src/components/sigil/sovereigncontrols.tsx","./src/components/sigil/stargateoverlay.tsx","./src/components/sigil/upgradesigilmodal.tsx","./src/components/sigil/openownershipverifymodal.tsx","./src/components/sigil/theme.tsx","./src/components/valuation/donorseditor.tsx","./src/components/valuation/mintcompositemodal.tsx","./src/components/valuation/asset.ts","./src/components/valuation/constants.ts","./src/components/valuation/display.ts","./src/components/valuation/drivers.ts","./src/components/valuation/globals.d.ts","./src/components/valuation/hooks.ts","./src/components/valuation/math.ts","./src/components/valuation/platform.ts","./src/components/valuation/rarity.ts","./src/components/valuation/series.ts","./src/components/valuation/chart/livechart.tsx","./src/components/valuation/chart/valuationcard.tsx","./src/components/valuation/chart/valuedonut.tsx","./src/components/valuation/types/window.d.ts","./src/components/verifier/sendphiamountfield.tsx","./src/components/verifier/verifiererrorboundary.tsx","./src/components/verifier/hooks/usemetasignals.ts","./src/components/verifier/types/local.ts","./src/components/verifier/types/window.d.ts","./src/components/verifier/ui/jsontree.tsx","./src/components/verifier/ui/statuschips.tsx","./src/components/verifier/utils/base64.ts","./src/components/verifier/utils/childexpiry.ts","./src/components/verifier/utils/decimal.ts","./src/components/verifier/utils/dialog.ts","./src/components/verifier/utils/log.ts","./src/components/verifier/utils/metadataset.ts","./src/components/verifier/utils/modal.ts","./src/components/verifier/utils/notepayload.ts","./src/components/verifier/utils/rotationbus.ts","./src/components/verifier/utils/saferead.ts","./src/components/verifier/utils/sigilglobal.ts","./src/components/verifier/utils/sigilmemoryvault.ts","./src/components/verifier/utils/statemachine.ts","./src/components/verifier/utils/urlpayload.ts","./src/constants/sigilexplorer.ts","./src/glyph/glyphmodal.tsx","./src/glyph/glyphengine.ts","./src/glyph/glyphutils.ts","./src/glyph/types.ts","./src/glyph/useglyphlogic.ts","./src/hooks/useauthorityproof.ts","./src/hooks/usebodyscrolllock.ts","./src/hooks/usedisablezoom.ts","./src/hooks/usefastpress.ts","./src/hooks/usekaiparitypricepoints.ts","./src/hooks/usekaiticker.ts","./src/hooks/useperfmode.ts","./src/hooks/useresponsivesigilsize.ts","./src/hooks/userotationbus.ts","./src/hooks/usevaluehistory.ts","./src/hooks/usevisualviewportsize.ts","./src/kai/kainow.ts","./src/lib/download.ts","./src/lib/hash.ts","./src/lib/mobilepopoverfix.ts","./src/lib/qr.ts","./src/lib/sigilregistryclient.ts","./src/lib/ledger/log.ts","./src/lib/ledger/merkle.ts","./src/lib/ledger/types.ts","./src/lib/sigil/breathproof.ts","./src/lib/sigil/canonicalize.ts","./src/lib/sigil/codec.ts","./src/lib/sigil/embed.ts","./src/lib/sigil/extract.ts","./src/lib/sigil/hash.ts","./src/lib/sigil/recover.ts","./src/lib/sigil/signature.ts","./src/lib/sigil/__tests__/canonicalize.test.ts","./src/lib/sigil/__tests__/hash.test.ts","./src/lib/sync/dht.ts","./src/lib/sync/ipfsadapter.ts","./src/lib/sync/nopadapter.ts","./src/og/buildverifiedcardsvg.ts","./src/og/cache.ts","./src/og/capsulestore.ts","./src/og/downloadverifiedcard.ts","./src/og/proofofbreathseal.ts","./src/og/rendernotfoundog.ts","./src/og/renderverifiedog.ts","./src/og/sigilembed.ts","./src/og/svgtopng.ts","./src/og/types.ts","./src/pages/pshort.tsx","./src/pages/sigilfeedpage.tsx","./src/pages/verifyembedpage.tsx","./src/pages/verifypage.tsx","./src/pages/verifysigil.tsx","./src/pages/sigilpage/sigilpage.tsx","./src/pages/sigilpage/constants.ts","./src/pages/sigilpage/debits.ts","./src/pages/sigilpage/descendants.ts","./src/pages/sigilpage/exportzip.ts","./src/pages/sigilpage/linkshare.ts","./src/pages/sigilpage/modalutils.ts","./src/pages/sigilpage/momentkeys.ts","./src/pages/sigilpage/ogimage.ts","./src/pages/sigilpage/posterexport.tsx","./src/pages/sigilpage/registry.ts","./src/pages/sigilpage/registrysign.ts","./src/pages/sigilpage/rotation.ts","./src/pages/sigilpage/rotationbus.ts","./src/pages/sigilpage/sendlock.ts","./src/pages/sigilpage/styleinject.ts","./src/pages/sigilpage/svgops.ts","./src/pages/sigilpage/types.ts","./src/pages/sigilpage/usesigilsend.ts","./src/pages/sigilpage/usevaluation.ts","./src/pages/sigilpage/utils.ts","./src/pages/sigilpage/verifiercanon.public.ts","./src/pages/sigilpage/verifiercanon.ts","./src/pages/sigilstream/sigilstreamroot.tsx","./src/pages/sigilstream/index.ts","./src/pages/sigilstream/attachments/embeds.tsx","./src/pages/sigilstream/attachments/files.ts","./src/pages/sigilstream/attachments/gallery.tsx","./src/pages/sigilstream/attachments/types.ts","./src/pages/sigilstream/composer/composer.tsx","./src/pages/sigilstream/composer/linkhelpers.ts","./src/pages/sigilstream/core/alias.ts","./src/pages/sigilstream/core/kai_time.ts","./src/pages/sigilstream/core/phistreamautoadd.ts","./src/pages/sigilstream/core/ticker.ts","./src/pages/sigilstream/core/types.ts","./src/pages/sigilstream/core/urldisplay.ts","./src/pages/sigilstream/core/utils.ts","./src/pages/sigilstream/data/memorystreamv2.ts","./src/pages/sigilstream/data/seed.ts","./src/pages/sigilstream/data/storage.ts","./src/pages/sigilstream/data/toast/toasts.tsx","./src/pages/sigilstream/data/toast/toast.ts","./src/pages/sigilstream/identity/identitybar.tsx","./src/pages/sigilstream/identity/sigilactionurl.tsx","./src/pages/sigilstream/inhaler/inhalesection.tsx","./src/pages/sigilstream/list/streamlist.tsx","./src/pages/sigilstream/payload/payloadbanner.tsx","./src/pages/sigilstream/payload/types.ts","./src/pages/sigilstream/payload/usepayload.ts","./src/pages/sigilstream/status/kaistatus.tsx","./src/pages/sigilstream/status/proofbadge.tsx","./src/perf/perfprofiler.tsx","./src/perf/perfdebug.tsx","./src/router/approuter.tsx","./src/session/sigilsession.tsx","./src/session/sigilsessioncontext.ts","./src/session/sigilsessiontypes.ts","./src/session/usesigilsession.ts","./src/ssr/ssrsnapshotcontext.tsx","./src/ssr/cache.ts","./src/ssr/loaders.ts","./src/ssr/safejson.ts","./src/ssr/serverexports.ts","./src/ssr/snapshotclient.ts","./src/ssr/snapshottypes.ts","./src/types/crypto-shims.d.ts","./src/types/global.d.ts","./src/types/jsqr.d.ts","./src/types/klocktypes.ts","./src/types/pako.d.ts","./src/types/react-router-dom-server.d.ts","./src/types/sigil-global.d.ts","./src/types/sigil.ts","./src/types/snarkjs-shim.d.ts","./src/types/snarkjs.d.ts","./src/types/usernameclaim.ts","./src/types/zkp-prover.d.ts","./src/utils/authorsig.ts","./src/utils/base64url.ts","./src/utils/cbor.ts","./src/utils/constants.ts","./src/utils/cryptoledger.ts","./src/utils/derivedglyph.ts","./src/utils/domhead.ts","./src/utils/extractkaimetadata.ts","./src/utils/feedpayload.ts","./src/utils/globaltokenregistry.ts","./src/utils/hash.ts","./src/utils/jcs.ts","./src/utils/kai.ts","./src/utils/kaimath.ts","./src/utils/kaitimedisplay.ts","./src/utils/kai_cadence.ts","./src/utils/kai_pulse.ts","./src/utils/kai_turah.ts","./src/utils/kairosmath.ts","./src/utils/klock_adapters.ts","./src/utils/kopyfeedback.ts","./src/utils/lahmahtor.ts","./src/utils/largeasset.ts","./src/utils/ownerphikey.ts","./src/utils/payload.ts","./src/utils/phi-issuance.ts","./src/utils/phi-precision.ts","./src/utils/platform.ts","./src/utils/pngchunks.ts","./src/utils/poseidon.ts","./src/utils/postseal.ts","./src/utils/provenance.ts","./src/utils/qrexport.ts","./src/utils/receivebundle.ts","./src/utils/reloaddetective.ts","./src/utils/sanitizehtml.ts","./src/utils/sendledger.ts","./src/utils/sendlock.ts","./src/utils/sha256.ts","./src/utils/shareurl.ts","./src/utils/shortener.ts","./src/utils/sigilauthextract.ts","./src/utils/sigilcapsule.ts","./src/utils/sigildecode.ts","./src/utils/sigilexplorersync.ts","./src/utils/sigilmetadata.ts","./src/utils/sigilregistry.ts","./src/utils/sigiltransferregistry.ts","./src/utils/sigilurl.ts","./src/utils/solarsync.ts","./src/utils/streamlink.ts","./src/utils/svgmeta.ts","./src/utils/svgproof.ts","./src/utils/transferpackage.ts","./src/utils/urlshort.ts","./src/utils/useclientready.ts","./src/utils/useportaltarget.ts","./src/utils/usesigilpayload copy.ts","./src/utils/usesigilpayload.ts","./src/utils/usesovereignsolarclock.ts","./src/utils/usernameclaim.ts","./src/utils/usernameclaimregistry.ts","./src/utils/valuation.ts","./src/utils/valuationsnapshot.ts","./src/utils/verificationcache.ts","./src/utils/verificationreceipt.ts","./src/utils/verificationversion.ts","./src/utils/verifysigil.ts","./src/utils/webauthnkas.ts","./src/utils/webauthnowner.ts","./src/utils/webauthnreceive.ts","./src/utils/zkproof.ts","./src/verifier/canonical.ts","./src/verifier/validator.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/vercel.json b/vercel.json index 863ee3683..735f4e9bf 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm run build:ssr", "functions": { "api/ssr.ts": { diff --git a/vite.config.ts b/vite.config.ts index 9e0cf299d..b8a7b9ecf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,6 +37,7 @@ export default defineConfig(({ isSsrBuild }) => { outDir: ssrBuild ? "dist/server" : "dist", ssrManifest: !ssrBuild, target: ssrBuild ? "node20" : undefined, + emptyOutDir: !ssrBuild, rollupOptions: { input: ssrBuild ? undefined : "index.html", },