diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 944a5238..cefb9785 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -677,6 +677,41 @@ async function finishMutation( return detail; } +export async function previewRuntimePublicKeyFingerprint( + publicKey: string, +): Promise { + const normalized = publicKey.trim(); + const prefix = "yoi-ed25519-pub:v1:"; + if (!normalized.startsWith(prefix)) { + throw new RuntimeTrustRequestError( + `Public key must start with ${prefix}`, + ); + } + const encoded = normalized.slice(prefix.length); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new RuntimeTrustRequestError("Public key encoding is invalid"); + } + const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") + + "=".repeat((4 - (encoded.length % 4)) % 4); + let decoded: string; + try { + decoded = atob(padded); + } catch { + throw new RuntimeTrustRequestError("Public key encoding is invalid"); + } + if (decoded.length !== 32) { + throw new RuntimeTrustRequestError("Public key must contain 32 bytes"); + } + const bytes = Uint8Array.from( + decoded, + (character) => character.charCodeAt(0), + ); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return `sha256:${hex}`; +} + export async function putRuntimeTrustKey( workspaceId: string, runtimeId: string, diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index e6f363b3..f085acea 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -470,6 +470,30 @@ color: var(--text-muted); } + .runtime-trust-comparison { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin: 0; + padding: var(--space-3) 0; + border-block: 1px solid var(--line); + } + + .runtime-trust-comparison div { + min-width: 0; + } + + .runtime-trust-comparison dt { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + } + + .runtime-trust-comparison dd { + margin: var(--space-1) 0 0; + overflow-wrap: anywhere; + } + .runtime-trust-form .field-error, .runtime-detail-page .section-state.error { color: var(--danger); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index e05b9663..13a36d5e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -6,6 +6,7 @@ RuntimeTrustKeyStatus, } from '$lib/generated/workspace-api'; import { + previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, @@ -23,6 +24,29 @@ let fieldError = $state(null); let requestError = $state(null); let successMessage = $state(null); + let replacementFingerprint = $state(null); + let replacementFingerprintError = $state(null); + let fingerprintGeneration = 0; + + $effect(() => { + const key = publicKey.trim(); + const generation = ++fingerprintGeneration; + replacementFingerprint = null; + replacementFingerprintError = null; + if (!key) return; + void previewRuntimePublicKeyFingerprint(key).then( + (fingerprint) => { + if (generation === fingerprintGeneration) replacementFingerprint = fingerprint; + }, + (error) => { + if (generation === fingerprintGeneration) { + replacementFingerprintError = error instanceof Error + ? error.message + : String(error); + } + }, + ); + }); function trustAction(status: RuntimeTrustKeyStatus): TrustAction { if (status === 'unconfigured') return 'create'; @@ -69,6 +93,14 @@ fieldError = 'Public key must be at most 16 KiB of UTF-8 text.'; return; } + if (replacementFingerprintError) { + fieldError = replacementFingerprintError; + return; + } + if (!replacementFingerprint) { + fieldError = 'Wait for the replacement fingerprint preview before saving.'; + return; + } const trust = data.runtimeDetail.trust_key; const action = trustAction(trust.status); @@ -249,9 +281,23 @@ spellcheck="false" aria-describedby={fieldError ? 'runtime-public-key-error' : undefined} aria-invalid={fieldError ? 'true' : undefined} - placeholder="ssh-ed25519 …" + placeholder="yoi-ed25519-pub:v1:…" > +
+
+
Current fingerprint
+
{trust.fingerprint ?? 'Not configured'}
+
+
+
Replacement fingerprint
+
{replacementFingerprint ?? 'Enter a valid public key'}
+
+
+ {#if replacementFingerprintError} +

{replacementFingerprintError}

+ {/if} + {#if currentAction !== 'create'} = 0, "Runtime trust controls should use manage_runtimes"); + assert( + page.includes("Current fingerprint"), + "current fingerprint must be explicit", + ); + assert( + page.includes("Replacement fingerprint"), + "replacement fingerprint must be previewed before confirmation", + ); assert( page.includes("!runtime.management.built_in"), "Runtime trust controls should be hidden for the built-in Runtime", diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index cc938db9..daf80b71 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -6,6 +6,7 @@ import { parseRuntimeTrustConflict, parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeList, + previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, RuntimeTrustConflictError, } from "../src/lib/workspace/api/runtime-management.ts"; @@ -180,6 +181,17 @@ Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", ); }); +Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => { + const fingerprint = await previewRuntimePublicKeyFingerprint( + "yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + assert( + fingerprint === + "sha256:66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925", + "fingerprint preview drifted from the Server SHA-256 contract", + ); +}); + Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => { let sentBody: unknown = null; const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {