fix: gate runtime key reveal and revoke confirmation
This commit is contained in:
@@ -276,7 +276,6 @@ export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||
|
||||
export type RuntimeTrustKeyState = {
|
||||
status: RuntimeTrustKeyStatus;
|
||||
public_key?: string | null;
|
||||
fingerprint?: string | null;
|
||||
revision?: number | null;
|
||||
created_at?: string | null;
|
||||
@@ -307,6 +306,8 @@ export type WorkspaceRuntimeDetail = {
|
||||
recent_audit: Array<RuntimeTrustAuditEntry>;
|
||||
};
|
||||
|
||||
export type RuntimeTrustKeyRevealResponse = { public_key: string };
|
||||
|
||||
export type PutRuntimeTrustKeyRequest = {
|
||||
public_key: string;
|
||||
expected_revision: number | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RuntimeTrustAuditEntry,
|
||||
RuntimeTrustConflictKind,
|
||||
RuntimeTrustConflictResponse,
|
||||
RuntimeTrustKeyRevealResponse,
|
||||
RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus,
|
||||
WorkspaceRuntimeDetail,
|
||||
@@ -354,23 +355,11 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||
exactKeys(
|
||||
item,
|
||||
["status"],
|
||||
[
|
||||
"public_key",
|
||||
"fingerprint",
|
||||
"revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"revoked_at",
|
||||
],
|
||||
["fingerprint", "revision", "created_at", "updated_at", "revoked_at"],
|
||||
path,
|
||||
);
|
||||
const result: RuntimeTrustKeyState = {
|
||||
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
|
||||
public_key: optionalNullableString(
|
||||
item.public_key,
|
||||
`${path}.public_key`,
|
||||
LIMITS.publicKeyBytes,
|
||||
),
|
||||
fingerprint: optionalNullableString(
|
||||
item.fingerprint,
|
||||
`${path}.fingerprint`,
|
||||
@@ -538,6 +527,25 @@ export function parseWorkspaceRuntimeDetail(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeTrustKeyRevealResponse(
|
||||
value: unknown,
|
||||
): RuntimeTrustKeyRevealResponse {
|
||||
const response = object(value, "Runtime trust key reveal response");
|
||||
exactKeys(
|
||||
response,
|
||||
["public_key"],
|
||||
[],
|
||||
"Runtime trust key reveal response",
|
||||
);
|
||||
return {
|
||||
public_key: boundedString(
|
||||
response.public_key,
|
||||
"Runtime trust key reveal response.public_key",
|
||||
LIMITS.publicKeyBytes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeTrustConflict(
|
||||
value: unknown,
|
||||
): RuntimeTrustConflictResponse {
|
||||
@@ -677,6 +685,21 @@ async function finishMutation(
|
||||
return detail;
|
||||
}
|
||||
|
||||
export async function revealRuntimeTrustKey(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<RuntimeTrustKeyRevealResponse> {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||
),
|
||||
);
|
||||
const payload = await readBoundedJson(response);
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
return parseRuntimeTrustKeyRevealResponse(payload);
|
||||
}
|
||||
|
||||
export async function previewRuntimePublicKeyFingerprint(
|
||||
publicKey: string,
|
||||
): Promise<string> {
|
||||
@@ -739,8 +762,15 @@ export async function revokeRuntimeTrustKey(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
request: RevokeRuntimeTrustKeyRequest,
|
||||
currentFingerprint: string,
|
||||
confirmation: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
if (!currentFingerprint || confirmation.trim() !== currentFingerprint) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Enter the current fingerprint exactly before revoking Workspace trust.",
|
||||
);
|
||||
}
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
|
||||
@@ -426,7 +426,8 @@
|
||||
|
||||
.runtime-public-key,
|
||||
.runtime-trust-form textarea,
|
||||
.runtime-trust-form input {
|
||||
.runtime-trust-form input,
|
||||
.runtime-revoke-row input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-raised);
|
||||
@@ -450,14 +451,16 @@
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.runtime-trust-form label {
|
||||
.runtime-trust-form label,
|
||||
.runtime-revoke-row label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-trust-form textarea,
|
||||
.runtime-trust-form input {
|
||||
.runtime-trust-form input,
|
||||
.runtime-revoke-row input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
@@ -466,7 +469,8 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.runtime-trust-form small {
|
||||
.runtime-trust-form small,
|
||||
.runtime-revoke-row small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
+79
-18
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
previewRuntimePublicKeyFingerprint,
|
||||
putRuntimeTrustKey,
|
||||
revealRuntimeTrustKey,
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRequestError,
|
||||
@@ -17,10 +18,12 @@
|
||||
type TrustAction = 'create' | 'replace' | 'reactivate';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let revealPublicKey = $state(false);
|
||||
let showPublicKey = $state(false);
|
||||
let revealedPublicKey = $state<string | null>(null);
|
||||
let publicKey = $state('');
|
||||
let fingerprintConfirmation = $state('');
|
||||
let busyAction = $state<'save' | 'revoke' | 'copy' | null>(null);
|
||||
let revokeFingerprintConfirmation = $state('');
|
||||
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
|
||||
let fieldError = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
@@ -125,7 +128,9 @@
|
||||
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
||||
publicKey = '';
|
||||
fingerprintConfirmation = '';
|
||||
revealPublicKey = false;
|
||||
revokeFingerprintConfirmation = '';
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
successMessage = action === 'create'
|
||||
? 'Workspace trust was created.'
|
||||
: action === 'replace'
|
||||
@@ -154,6 +159,13 @@
|
||||
requestError = 'Only active Workspace trust can be revoked.';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!trust.fingerprint ||
|
||||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
|
||||
) {
|
||||
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
|
||||
return;
|
||||
}
|
||||
|
||||
fieldError = null;
|
||||
requestError = null;
|
||||
@@ -164,10 +176,18 @@
|
||||
};
|
||||
|
||||
try {
|
||||
await revokeRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
||||
await revokeRuntimeTrustKey(
|
||||
data.workspaceId,
|
||||
data.runtimeId,
|
||||
request,
|
||||
trust.fingerprint,
|
||||
revokeFingerprintConfirmation,
|
||||
);
|
||||
publicKey = '';
|
||||
fingerprintConfirmation = '';
|
||||
revealPublicKey = false;
|
||||
revokeFingerprintConfirmation = '';
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
successMessage = 'Workspace trust was revoked.';
|
||||
await reloadAuthority();
|
||||
} catch (error) {
|
||||
@@ -182,16 +202,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePublicKeyReveal(): Promise<void> {
|
||||
if (showPublicKey) {
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
return;
|
||||
}
|
||||
if (busyAction !== null) return;
|
||||
busyAction = 'reveal';
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
try {
|
||||
const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId);
|
||||
revealedPublicKey = response.public_key;
|
||||
showPublicKey = true;
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicKey(): Promise<void> {
|
||||
const key = data.runtimeDetail?.trust_key.public_key;
|
||||
if (!key || busyAction !== null) return;
|
||||
if (busyAction !== null) return;
|
||||
busyAction = 'copy';
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId);
|
||||
await navigator.clipboard.writeText(response.public_key);
|
||||
successMessage = 'Public key copied.';
|
||||
} catch {
|
||||
requestError = 'The browser could not copy the public key.';
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error
|
||||
? error.message
|
||||
: 'The browser could not copy the public key.';
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
@@ -255,20 +299,23 @@
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
||||
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
||||
|
||||
{#if trust.public_key}
|
||||
{#if trust.status !== 'unconfigured'}
|
||||
<div class="runtime-public-key-actions">
|
||||
<button type="button" class="secondary" onclick={() => revealPublicKey = !revealPublicKey}>
|
||||
{revealPublicKey ? 'Hide public key' : 'Reveal public key'}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
disabled={busyAction !== null}
|
||||
onclick={togglePublicKeyReveal}
|
||||
>
|
||||
{busyAction === 'reveal' ? 'Loading…' : showPublicKey ? 'Hide public key' : 'Reveal public key'}
|
||||
</button>
|
||||
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
|
||||
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
|
||||
</button>
|
||||
</div>
|
||||
{#if revealPublicKey}
|
||||
<pre class="runtime-public-key"><code>{trust.public_key}</code></pre>
|
||||
{#if showPublicKey && revealedPublicKey}
|
||||
<pre class="runtime-public-key"><code>{revealedPublicKey}</code></pre>
|
||||
{/if}
|
||||
{:else if trust.status !== 'unconfigured'}
|
||||
<p class="section-state">The public key was not included in this authorized response.</p>
|
||||
{/if}
|
||||
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
@@ -324,11 +371,25 @@
|
||||
<div>
|
||||
<strong>Revoke Workspace trust</strong>
|
||||
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
|
||||
<label>
|
||||
Confirm current fingerprint
|
||||
<input
|
||||
bind:value={revokeFingerprintConfirmation}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={trust.status !== 'active' || busyAction !== null}
|
||||
/>
|
||||
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly before revocation.</small>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
disabled={busyAction !== null || trust.status !== 'active'}
|
||||
disabled={
|
||||
busyAction !== null ||
|
||||
trust.status !== 'active' ||
|
||||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
|
||||
}
|
||||
onclick={revokeTrust}
|
||||
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
|
||||
</div>
|
||||
|
||||
@@ -110,6 +110,8 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
"Revoke Workspace trust",
|
||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||
"RuntimeTrustConflictError",
|
||||
"revealRuntimeTrustKey",
|
||||
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
|
||||
"await reloadAuthority()",
|
||||
"busyAction !== null",
|
||||
"Workdirs",
|
||||
|
||||
@@ -4,10 +4,12 @@ declare const Deno: {
|
||||
|
||||
import {
|
||||
parseRuntimeTrustConflict,
|
||||
parseRuntimeTrustKeyRevealResponse,
|
||||
parseWorkspaceRuntimeDetail,
|
||||
parseWorkspaceRuntimeList,
|
||||
previewRuntimePublicKeyFingerprint,
|
||||
putRuntimeTrustKey,
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||
|
||||
@@ -62,7 +64,6 @@ function detail() {
|
||||
endpoint: "https://runtime.example.test",
|
||||
trust_key: {
|
||||
status: "active",
|
||||
public_key: "ssh-ed25519 AAAA-test",
|
||||
fingerprint: "SHA256:current",
|
||||
revision: 3,
|
||||
created_at: "2026-09-01T12:00:00Z",
|
||||
@@ -162,10 +163,11 @@ Deno.test("Runtime validators reject unsafe revisions and bounded collection ove
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => {
|
||||
const largeKey = structuredClone(detail());
|
||||
largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1);
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(largeKey),
|
||||
() =>
|
||||
parseRuntimeTrustKeyRevealResponse({
|
||||
public_key: "x".repeat(16 * 1024 + 1),
|
||||
}),
|
||||
"must be at most 16384 UTF-8 bytes",
|
||||
);
|
||||
|
||||
@@ -181,6 +183,30 @@ Deno.test("Runtime detail rejects unbounded strings and incoherent trust state",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("mismatched revoke fingerprint never sends a request", async () => {
|
||||
let requests = 0;
|
||||
const fetchImpl: typeof fetch = () => {
|
||||
requests += 1;
|
||||
return Promise.reject(new Error("request must not be sent"));
|
||||
};
|
||||
let rejected = false;
|
||||
try {
|
||||
await revokeRuntimeTrustKey(
|
||||
"workspace-a",
|
||||
"runtime-a",
|
||||
{ expected_revision: 3 },
|
||||
"sha256:current",
|
||||
"sha256:different",
|
||||
fetchImpl,
|
||||
);
|
||||
} catch (error) {
|
||||
rejected = error instanceof Error &&
|
||||
error.message.includes("current fingerprint exactly");
|
||||
}
|
||||
assert(rejected, "mismatched fingerprint should be rejected locally");
|
||||
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
||||
});
|
||||
|
||||
Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => {
|
||||
const fingerprint = await previewRuntimePublicKeyFingerprint(
|
||||
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
|
||||
Reference in New Issue
Block a user