fix: lock verified runtime public keys
This commit is contained in:
@@ -496,6 +496,11 @@ export type CreateRemoteRuntimeRequest = {
|
||||
expected_revision?: number | null;
|
||||
};
|
||||
|
||||
export type UpdateRemoteRuntimeRequest = {
|
||||
display_name?: string | null;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||
|
||||
export type RuntimeConnectionTestFailureKind =
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus,
|
||||
RuntimeVerificationEvidenceSummary,
|
||||
UpdateRemoteRuntimeRequest,
|
||||
WorkspaceRuntimeBindingState,
|
||||
WorkspaceRuntimeBindingSummary,
|
||||
WorkspaceRuntimeDetail,
|
||||
@@ -919,6 +920,26 @@ export async function createRemoteRuntime(
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export async function updateRemoteRuntime(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
request: UpdateRemoteRuntimeRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}`,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
return finishMutation(response, workspaceId, runtimeId);
|
||||
}
|
||||
|
||||
export async function deleteRemoteRuntime(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
|
||||
@@ -445,6 +445,20 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-public-key-readonly {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.runtime-public-key-readonly p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-public-key-readonly code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-trust-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
|
||||
+110
-5
@@ -10,6 +10,7 @@
|
||||
previewRuntimePublicKeyFingerprint,
|
||||
revealRuntimeTrustKey,
|
||||
revokeRuntimeTrustKey,
|
||||
updateRemoteRuntime,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRouteFence,
|
||||
RuntimeTrustRequestError,
|
||||
@@ -23,8 +24,11 @@
|
||||
let showPublicKey = $state(false);
|
||||
let revealedPublicKey = $state<string | null>(null);
|
||||
let publicKey = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let editingMetadata = $state(false);
|
||||
let deleteRuntimeConfirmation = $state('');
|
||||
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
||||
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
||||
let fieldError = $state<string | null>(null);
|
||||
let deleteRuntimeError = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
@@ -43,6 +47,9 @@
|
||||
showPublicKey = false;
|
||||
revealedPublicKey = null;
|
||||
publicKey = '';
|
||||
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||
editingMetadata = false;
|
||||
deleteRuntimeConfirmation = '';
|
||||
busyAction = null;
|
||||
fieldError = null;
|
||||
@@ -105,6 +112,49 @@
|
||||
return routeFence.isCurrent(operation, data.runtimeId);
|
||||
}
|
||||
|
||||
function cancelRuntimeMetadataEdit(): void {
|
||||
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||
editingMetadata = false;
|
||||
requestError = null;
|
||||
}
|
||||
|
||||
async function saveRuntimeMetadata(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
const normalizedDisplayName = displayName.trim();
|
||||
const normalizedEndpoint = endpoint.trim();
|
||||
if (!normalizedDisplayName) {
|
||||
requestError = 'Enter a Runtime label.';
|
||||
return;
|
||||
}
|
||||
if (!normalizedEndpoint) {
|
||||
requestError = 'Enter the Runtime endpoint.';
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = routeFence.capture(data.runtimeId);
|
||||
busyAction = 'metadata';
|
||||
try {
|
||||
await updateRemoteRuntime(data.workspaceId, operation.runtimeId, {
|
||||
display_name: normalizedDisplayName,
|
||||
endpoint: normalizedEndpoint,
|
||||
});
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
successMessage = 'Runtime settings were updated.';
|
||||
editingMetadata = false;
|
||||
await reloadAuthority();
|
||||
} catch (error) {
|
||||
if (!isCurrentRoute(operation)) return;
|
||||
requestError = error instanceof Error ? error.message : 'Runtime settings update failed.';
|
||||
} finally {
|
||||
if (isCurrentRoute(operation)) busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
@@ -135,7 +185,7 @@
|
||||
const action = trustAction(trust.status);
|
||||
|
||||
const operation = routeFence.capture(data.runtimeId);
|
||||
busyAction = 'save';
|
||||
busyAction = 'trust';
|
||||
try {
|
||||
const binding = data.runtimeDetail.runtime.management.binding;
|
||||
if (!binding || !data.runtimeDetail.endpoint) {
|
||||
@@ -339,6 +389,7 @@
|
||||
{@const detail = data.runtimeDetail}
|
||||
{@const runtime = detail.runtime}
|
||||
{@const trust = detail.trust_key}
|
||||
{@const verifiedTrust = runtime.management.binding?.state === 'verified'}
|
||||
{@const currentAction = trustAction(trust.status)}
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||
@@ -373,6 +424,50 @@
|
||||
</section>
|
||||
|
||||
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-settings-heading">
|
||||
<h2 id="runtime-settings-heading">Runtime settings</h2>
|
||||
{#if editingMetadata}
|
||||
<form class="runtime-trust-form" onsubmit={saveRuntimeMetadata}>
|
||||
<label for="runtime-display-name-input">Label</label>
|
||||
<input
|
||||
id="runtime-display-name-input"
|
||||
bind:value={displayName}
|
||||
autocomplete="off"
|
||||
disabled={busyAction !== null}
|
||||
/>
|
||||
<label for="runtime-endpoint-input">Endpoint</label>
|
||||
<input
|
||||
id="runtime-endpoint-input"
|
||||
bind:value={endpoint}
|
||||
inputmode="url"
|
||||
autocomplete="url"
|
||||
spellcheck="false"
|
||||
disabled={busyAction !== null}
|
||||
/>
|
||||
<div class="settings-action-row">
|
||||
<button type="submit" disabled={busyAction !== null}>
|
||||
{busyAction === 'metadata' ? 'Saving…' : 'Save Runtime settings'}
|
||||
</button>
|
||||
<button
|
||||
class="settings-secondary-action"
|
||||
type="button"
|
||||
onclick={cancelRuntimeMetadataEdit}
|
||||
disabled={busyAction !== null}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="settings-action-row">
|
||||
<button
|
||||
class="settings-secondary-action"
|
||||
type="button"
|
||||
onclick={() => (editingMetadata = true)}
|
||||
disabled={busyAction !== null}
|
||||
>Edit Runtime</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
||||
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
||||
|
||||
@@ -395,7 +490,16 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
{#if verifiedTrust}
|
||||
<div class="runtime-public-key-readonly">
|
||||
<strong>Runtime public key</strong>
|
||||
<p>
|
||||
This verified key is read-only. Revoke Workspace trust and register the Runtime again to use a different public key.
|
||||
</p>
|
||||
<code>{trust.fingerprint}</code>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
<label for="runtime-public-key-input">Runtime public key</label>
|
||||
<textarea
|
||||
id="runtime-public-key-input"
|
||||
@@ -427,10 +531,11 @@
|
||||
{/if}
|
||||
<div class="settings-action-row">
|
||||
<button type="submit" disabled={busyAction !== null}>
|
||||
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
|
||||
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="runtime-revoke-row">
|
||||
<div>
|
||||
|
||||
@@ -129,8 +129,29 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
|
||||
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||
const reveal = page.indexOf("Reveal public key");
|
||||
const verifiedKey = page.indexOf("{#if verifiedTrust}");
|
||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||
const metadataStart = page.indexOf("async function saveRuntimeMetadata");
|
||||
const trustStart = page.indexOf("async function saveTrustKey");
|
||||
const metadataMutation = page.slice(metadataStart, trustStart);
|
||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||
assert(
|
||||
verifiedKey >= 0 && verifiedKey < mutation &&
|
||||
page.slice(verifiedKey, mutation).includes("{:else}"),
|
||||
"verified Runtime public key must be read-only while unverified trust keeps key input",
|
||||
);
|
||||
assert(
|
||||
metadataMutation.includes("updateRemoteRuntime(") &&
|
||||
metadataMutation.includes("display_name: normalizedDisplayName") &&
|
||||
metadataMutation.includes("endpoint: normalizedEndpoint"),
|
||||
"mutable Runtime settings should use the metadata-only update request",
|
||||
);
|
||||
assert(
|
||||
!metadataMutation.includes("publicKey") &&
|
||||
!metadataMutation.includes("public_bundle") &&
|
||||
!metadataMutation.includes("public_key"),
|
||||
"verified Runtime metadata updates must not carry public key authority",
|
||||
);
|
||||
assert(
|
||||
page.includes("Current fingerprint"),
|
||||
"current fingerprint must be explicit",
|
||||
@@ -168,6 +189,10 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
"routeFence.enter(data.runtimeId)",
|
||||
"showPublicKey = false",
|
||||
"revealedPublicKey = null",
|
||||
"saveRuntimeMetadata",
|
||||
"Runtime settings",
|
||||
"Edit Runtime",
|
||||
"This verified key is read-only",
|
||||
"publicKey = ''",
|
||||
"requestError = null",
|
||||
"successMessage = null",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRouteFence,
|
||||
updateRemoteRuntime,
|
||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
@@ -273,6 +274,45 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
|
||||
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
||||
});
|
||||
|
||||
Deno.test("Runtime metadata update never sends public key authority", async () => {
|
||||
let requestedUrl = "";
|
||||
let requestedMethod = "";
|
||||
let requestedBody: unknown = null;
|
||||
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
|
||||
requestedUrl = String(input);
|
||||
requestedMethod = init?.method ?? "GET";
|
||||
requestedBody = JSON.parse(String(init?.body));
|
||||
return Promise.resolve(Response.json(detail()));
|
||||
}) as typeof fetch;
|
||||
|
||||
await updateRemoteRuntime(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
{
|
||||
display_name: "Updated Runtime",
|
||||
endpoint: "https://runtime.example.test/v2",
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
assert(
|
||||
requestedUrl === "/api/w/workspace-a/runtimes/arcadia",
|
||||
`unexpected update URL: ${requestedUrl}`,
|
||||
);
|
||||
assert(requestedMethod === "POST", "Runtime update must use POST");
|
||||
assert(
|
||||
JSON.stringify(requestedBody) ===
|
||||
JSON.stringify({
|
||||
display_name: "Updated Runtime",
|
||||
endpoint: "https://runtime.example.test/v2",
|
||||
}),
|
||||
`unexpected update body: ${JSON.stringify(requestedBody)}`,
|
||||
);
|
||||
const body = requestedBody as Record<string, unknown>;
|
||||
assert(!("public_bundle" in body), "metadata update sent public_bundle");
|
||||
assert(!("public_key" in body), "metadata update sent public_key");
|
||||
});
|
||||
|
||||
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
|
||||
let requestedUrl = "";
|
||||
let requestedMethod = "";
|
||||
|
||||
Reference in New Issue
Block a user