fix: preview runtime trust rotation fingerprint
This commit is contained in:
@@ -677,6 +677,41 @@ async function finishMutation(
|
|||||||
return detail;
|
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(
|
export async function putRuntimeTrustKey(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
|||||||
@@ -470,6 +470,30 @@
|
|||||||
color: var(--text-muted);
|
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-trust-form .field-error,
|
||||||
.runtime-detail-page .section-state.error {
|
.runtime-detail-page .section-state.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|||||||
+47
-1
@@ -6,6 +6,7 @@
|
|||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
} from '$lib/generated/workspace-api';
|
} from '$lib/generated/workspace-api';
|
||||||
import {
|
import {
|
||||||
|
previewRuntimePublicKeyFingerprint,
|
||||||
putRuntimeTrustKey,
|
putRuntimeTrustKey,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
@@ -23,6 +24,29 @@
|
|||||||
let fieldError = $state<string | null>(null);
|
let fieldError = $state<string | null>(null);
|
||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
let successMessage = $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 {
|
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
|
||||||
if (status === 'unconfigured') return 'create';
|
if (status === 'unconfigured') return 'create';
|
||||||
@@ -69,6 +93,14 @@
|
|||||||
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
|
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
|
||||||
return;
|
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 trust = data.runtimeDetail.trust_key;
|
||||||
const action = trustAction(trust.status);
|
const action = trustAction(trust.status);
|
||||||
@@ -249,9 +281,23 @@
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
|
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
|
||||||
aria-invalid={fieldError ? 'true' : undefined}
|
aria-invalid={fieldError ? 'true' : undefined}
|
||||||
placeholder="ssh-ed25519 …"
|
placeholder="yoi-ed25519-pub:v1:…"
|
||||||
></textarea>
|
></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'}
|
{#if currentAction !== 'create'}
|
||||||
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
|
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
|
||||||
<input
|
<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 reveal = page.indexOf("Reveal public key");
|
||||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
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(
|
assert(
|
||||||
page.includes("!runtime.management.built_in"),
|
page.includes("!runtime.management.built_in"),
|
||||||
"Runtime trust controls should be hidden for the built-in Runtime",
|
"Runtime trust controls should be hidden for the built-in Runtime",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
parseRuntimeTrustConflict,
|
parseRuntimeTrustConflict,
|
||||||
parseWorkspaceRuntimeDetail,
|
parseWorkspaceRuntimeDetail,
|
||||||
parseWorkspaceRuntimeList,
|
parseWorkspaceRuntimeList,
|
||||||
|
previewRuntimePublicKeyFingerprint,
|
||||||
putRuntimeTrustKey,
|
putRuntimeTrustKey,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
} 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 () => {
|
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
|
||||||
let sentBody: unknown = null;
|
let sentBody: unknown = null;
|
||||||
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
|
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user