fix: preview runtime trust rotation fingerprint

This commit is contained in:
2026-09-06 05:25:03 +09:00
parent 2cd57a32b2
commit 5686bbc9fd
5 changed files with 126 additions and 1 deletions
@@ -677,6 +677,41 @@ async function finishMutation(
return detail;
}
export async function previewRuntimePublicKeyFingerprint(
publicKey: string,
): Promise<string> {
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,
@@ -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);
@@ -6,6 +6,7 @@
RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api';
import {
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
@@ -23,6 +24,29 @@
let fieldError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null);
let replacementFingerprintError = $state<string | null>(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:…"
></textarea>
<dl class="runtime-trust-comparison">
<div>
<dt>Current fingerprint</dt>
<dd><code>{trust.fingerprint ?? 'Not configured'}</code></dd>
</div>
<div>
<dt>Replacement fingerprint</dt>
<dd><code>{replacementFingerprint ?? 'Enter a valid public key'}</code></dd>
</div>
</dl>
{#if replacementFingerprintError}
<p class="field-error">{replacementFingerprintError}</p>
{/if}
{#if currentAction !== 'create'}
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
<input
@@ -84,6 +84,14 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
const reveal = page.indexOf("Reveal public key");
const mutation = page.indexOf('id="runtime-public-key-input"');
assert(ownerGate >= 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",
@@ -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) => {