fix: preview runtime trust rotation fingerprint
This commit is contained in:
@@ -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);
|
||||
|
||||
+47
-1
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user