fix: gate runtime key reveal and revoke confirmation

This commit is contained in:
2026-09-06 05:50:29 +09:00
parent 5686bbc9fd
commit 5fd2ccf084
9 changed files with 253 additions and 71 deletions
+13 -3
View File
@@ -13,9 +13,9 @@ use workspace_api::{
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
WorkspaceRuntimeResource, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
}; };
use crate::{BackendApiClient, BackendWorkspaceClientError}; use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -256,6 +256,16 @@ impl BackendWorkspaceProductClient {
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id))) self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
} }
pub fn reveal_runtime_trust_key(
&self,
runtime_id: &str,
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
self.get_json(&format!(
"/runtimes/{}/trust-key",
encode_path_segment(runtime_id)
))
}
pub fn put_runtime_trust_key( pub fn put_runtime_trust_key(
&self, &self,
runtime_id: &str, runtime_id: &str,
+15 -3
View File
@@ -1220,8 +1220,6 @@ pub enum RuntimeTrustKeyStatus {
pub struct RuntimeTrustKeyState { pub struct RuntimeTrustKeyState {
pub status: RuntimeTrustKeyStatus, pub status: RuntimeTrustKeyStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>, pub fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))] #[cfg_attr(feature = "typescript", ts(type = "number | null"))]
@@ -1272,6 +1270,13 @@ pub struct WorkspaceRuntimeDetail {
pub recent_audit: Vec<RuntimeTrustAuditEntry>, pub recent_audit: Vec<RuntimeTrustAuditEntry>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeTrustKeyRevealResponse {
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -2522,6 +2527,7 @@ pub fn catalog_typescript() -> String {
RuntimeTrustAuditAction::decl(&config), RuntimeTrustAuditAction::decl(&config),
RuntimeTrustAuditEntry::decl(&config), RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config), WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config), PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictKind::decl(&config),
@@ -3250,7 +3256,6 @@ mod tests {
"endpoint": "https://runtime.example", "endpoint": "https://runtime.example",
"trust_key": { "trust_key": {
"status": "active", "status": "active",
"public_key": "ssh-ed25519 AAAA runtime-test",
"fingerprint": "SHA256:test", "fingerprint": "SHA256:test",
"revision": 2, "revision": 2,
"created_at": "2026-09-01T12:00:00Z", "created_at": "2026-09-01T12:00:00Z",
@@ -3271,6 +3276,13 @@ mod tests {
let mut unknown = detail; let mut unknown = detail;
unknown["trust_key"]["private_key"] = serde_json::json!("forbidden"); unknown["trust_key"]["private_key"] = serde_json::json!("forbidden");
assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).is_err()); assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).is_err());
assert!(
serde_json::from_value::<RuntimeTrustKeyRevealResponse>(serde_json::json!({
"public_key": "yoi-ed25519-pub:v1:key",
"private_key": "forbidden"
}))
.is_err()
);
assert!( assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({ serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key", "public_key": "key",
+61 -25
View File
@@ -78,10 +78,11 @@ use workspace_api::{
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest, RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry, RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyState, RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse,
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption,
WorkerLaunchWorkerSummary,
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
@@ -2672,7 +2673,9 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
) )
.route( .route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_trust_key), get(scoped_reveal_runtime_trust_key)
.put(scoped_put_runtime_trust_key)
.delete(scoped_revoke_runtime_trust_key),
) )
.route( .route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
@@ -10884,20 +10887,44 @@ async fn scoped_create_remote_runtime(
async fn scoped_get_runtime_detail( async fn scoped_get_runtime_detail(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
Extension(actor): Extension<RequestActor>,
) -> ApiResult<Json<WorkspaceRuntimeDetail>> { ) -> ApiResult<Json<WorkspaceRuntimeDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let workspace = api
.store
.get_workspace(&path.workspace_id)
.await?
.ok_or(Error::WorkspaceIdMismatch)?;
let is_owner = workspace.owner_account_id == actor.account_id;
Ok(Json( Ok(Json(
workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?, workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?,
)) ))
} }
async fn scoped_reveal_runtime_trust_key(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
Extension(actor): Extension<RequestActor>,
) -> ApiResult<Json<RuntimeTrustKeyRevealResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
require_workspace_owner(
&api,
&path.workspace_id,
&actor,
"Runtime public key reveal",
)
.await?;
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
));
}
let binding = api
.store
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
.await?
.ok_or_else(|| Error::RuntimeBindingNotFound {
runtime_id: path.runtime_id.clone(),
})?;
Ok(Json(RuntimeTrustKeyRevealResponse {
public_key: binding.public_key,
}))
}
async fn scoped_put_runtime_trust_key( async fn scoped_put_runtime_trust_key(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
@@ -10993,7 +11020,7 @@ async fn scoped_put_runtime_trust_key(
.register_remote_runtime(source); .register_remote_runtime(source);
} }
Ok( Ok(
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?) Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?)
.into_response(), .into_response(),
) )
} }
@@ -11046,7 +11073,7 @@ async fn scoped_revoke_runtime_trust_key(
api.runtime_subscription_broker api.runtime_subscription_broker
.unregister_runtime(&path.runtime_id); .unregister_runtime(&path.runtime_id);
Ok( Ok(
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?) Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?)
.into_response(), .into_response(),
) )
} }
@@ -14738,7 +14765,6 @@ async fn workspace_runtime_detail(
api: &WorkspaceApi, api: &WorkspaceApi,
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
include_public_key: bool,
) -> ApiResult<WorkspaceRuntimeDetail> { ) -> ApiResult<WorkspaceRuntimeDetail> {
let binding = api let binding = api
.store .store
@@ -14800,7 +14826,6 @@ async fn workspace_runtime_detail(
let trust_key = binding.as_ref().map_or( let trust_key = binding.as_ref().map_or(
RuntimeTrustKeyState { RuntimeTrustKeyState {
status: RuntimeTrustKeyStatus::Unconfigured, status: RuntimeTrustKeyStatus::Unconfigured,
public_key: None,
fingerprint: None, fingerprint: None,
revision: None, revision: None,
created_at: None, created_at: None,
@@ -14813,7 +14838,6 @@ async fn workspace_runtime_detail(
} else { } else {
RuntimeTrustKeyStatus::Active RuntimeTrustKeyStatus::Active
}, },
public_key: include_public_key.then(|| binding.public_key.clone()),
fingerprint: Some(binding.public_key_fingerprint.clone()), fingerprint: Some(binding.public_key_fingerprint.clone()),
revision: Some(binding.binding_revision), revision: Some(binding.binding_revision),
created_at: Some(binding.created_at.clone()), created_at: Some(binding.created_at.clone()),
@@ -22529,7 +22553,18 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let Json(owner_detail) = scoped_get_runtime_detail( let Json(detail) = scoped_get_runtime_detail(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
)
.await
.unwrap();
assert_eq!(detail.trust_key.revision, Some(1));
assert!(detail.trust_key.fingerprint.is_some());
let Json(revealed) = scoped_reveal_runtime_trust_key(
State(api.clone()), State(api.clone()),
AxumPath(ScopedRuntimePath { AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
@@ -22539,9 +22574,8 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert!(owner_detail.trust_key.public_key.is_some()); assert!(revealed.public_key.starts_with("yoi-ed25519-pub:v1:"));
assert_eq!(owner_detail.trust_key.revision, Some(1)); let denied_reveal = scoped_reveal_runtime_trust_key(
let Json(reader_detail) = scoped_get_runtime_detail(
State(api.clone()), State(api.clone()),
AxumPath(ScopedRuntimePath { AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
@@ -22550,9 +22584,11 @@ mod tests {
Extension(non_owner.clone()), Extension(non_owner.clone()),
) )
.await .await
.unwrap(); .unwrap_err();
assert!(reader_detail.trust_key.public_key.is_none()); assert_eq!(
assert!(reader_detail.trust_key.fingerprint.is_some()); denied_reveal.into_response().status(),
StatusCode::FORBIDDEN
);
let response = scoped_put_runtime_trust_key( let response = scoped_put_runtime_trust_key(
State(api.clone()), State(api.clone()),
@@ -276,7 +276,6 @@ export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
export type RuntimeTrustKeyState = { export type RuntimeTrustKeyState = {
status: RuntimeTrustKeyStatus; status: RuntimeTrustKeyStatus;
public_key?: string | null;
fingerprint?: string | null; fingerprint?: string | null;
revision?: number | null; revision?: number | null;
created_at?: string | null; created_at?: string | null;
@@ -307,6 +306,8 @@ export type WorkspaceRuntimeDetail = {
recent_audit: Array<RuntimeTrustAuditEntry>; recent_audit: Array<RuntimeTrustAuditEntry>;
}; };
export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = { export type PutRuntimeTrustKeyRequest = {
public_key: string; public_key: string;
expected_revision: number | null; expected_revision: number | null;
@@ -11,6 +11,7 @@ import type {
RuntimeTrustAuditEntry, RuntimeTrustAuditEntry,
RuntimeTrustConflictKind, RuntimeTrustConflictKind,
RuntimeTrustConflictResponse, RuntimeTrustConflictResponse,
RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState, RuntimeTrustKeyState,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
WorkspaceRuntimeDetail, WorkspaceRuntimeDetail,
@@ -354,23 +355,11 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
exactKeys( exactKeys(
item, item,
["status"], ["status"],
[ ["fingerprint", "revision", "created_at", "updated_at", "revoked_at"],
"public_key",
"fingerprint",
"revision",
"created_at",
"updated_at",
"revoked_at",
],
path, path,
); );
const result: RuntimeTrustKeyState = { const result: RuntimeTrustKeyState = {
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES), status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
public_key: optionalNullableString(
item.public_key,
`${path}.public_key`,
LIMITS.publicKeyBytes,
),
fingerprint: optionalNullableString( fingerprint: optionalNullableString(
item.fingerprint, item.fingerprint,
`${path}.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( export function parseRuntimeTrustConflict(
value: unknown, value: unknown,
): RuntimeTrustConflictResponse { ): RuntimeTrustConflictResponse {
@@ -677,6 +685,21 @@ async function finishMutation(
return detail; 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( export async function previewRuntimePublicKeyFingerprint(
publicKey: string, publicKey: string,
): Promise<string> { ): Promise<string> {
@@ -739,8 +762,15 @@ export async function revokeRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
request: RevokeRuntimeTrustKeyRequest, request: RevokeRuntimeTrustKeyRequest,
currentFingerprint: string,
confirmation: string,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> { ): Promise<WorkspaceRuntimeDetail> {
if (!currentFingerprint || confirmation.trim() !== currentFingerprint) {
throw new RuntimeTrustRequestError(
"Enter the current fingerprint exactly before revoking Workspace trust.",
);
}
const response = await fetchImpl( const response = await fetchImpl(
workspaceApiPath( workspaceApiPath(
workspaceId, workspaceId,
@@ -426,7 +426,8 @@
.runtime-public-key, .runtime-public-key,
.runtime-trust-form textarea, .runtime-trust-form textarea,
.runtime-trust-form input { .runtime-trust-form input,
.runtime-revoke-row input {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.5rem; border-radius: 0.5rem;
background: var(--bg-raised); background: var(--bg-raised);
@@ -450,14 +451,16 @@
max-width: 56rem; max-width: 56rem;
} }
.runtime-trust-form label { .runtime-trust-form label,
.runtime-revoke-row label {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
} }
.runtime-trust-form textarea, .runtime-trust-form textarea,
.runtime-trust-form input { .runtime-trust-form input,
.runtime-revoke-row input {
width: 100%; width: 100%;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
} }
@@ -466,7 +469,8 @@
resize: vertical; resize: vertical;
} }
.runtime-trust-form small { .runtime-trust-form small,
.runtime-revoke-row small {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -8,6 +8,7 @@
import { import {
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey, putRuntimeTrustKey,
revealRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
RuntimeTrustRequestError, RuntimeTrustRequestError,
@@ -17,10 +18,12 @@
type TrustAction = 'create' | 'replace' | 'reactivate'; type TrustAction = 'create' | 'replace' | 'reactivate';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let revealPublicKey = $state(false); let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state(''); let publicKey = $state('');
let fingerprintConfirmation = $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 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);
@@ -125,7 +128,9 @@
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request); await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
publicKey = ''; publicKey = '';
fingerprintConfirmation = ''; fingerprintConfirmation = '';
revealPublicKey = false; revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = action === 'create' successMessage = action === 'create'
? 'Workspace trust was created.' ? 'Workspace trust was created.'
: action === 'replace' : action === 'replace'
@@ -154,6 +159,13 @@
requestError = 'Only active Workspace trust can be revoked.'; requestError = 'Only active Workspace trust can be revoked.';
return; return;
} }
if (
!trust.fingerprint ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
) {
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
return;
}
fieldError = null; fieldError = null;
requestError = null; requestError = null;
@@ -164,10 +176,18 @@
}; };
try { try {
await revokeRuntimeTrustKey(data.workspaceId, data.runtimeId, request); await revokeRuntimeTrustKey(
data.workspaceId,
data.runtimeId,
request,
trust.fingerprint,
revokeFingerprintConfirmation,
);
publicKey = ''; publicKey = '';
fingerprintConfirmation = ''; fingerprintConfirmation = '';
revealPublicKey = false; revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.'; successMessage = 'Workspace trust was revoked.';
await reloadAuthority(); await reloadAuthority();
} catch (error) { } 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> { async function copyPublicKey(): Promise<void> {
const key = data.runtimeDetail?.trust_key.public_key; if (busyAction !== null) return;
if (!key || busyAction !== null) return;
busyAction = 'copy'; busyAction = 'copy';
requestError = null; requestError = null;
successMessage = null;
try { 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.'; successMessage = 'Public key copied.';
} catch { } catch (error) {
requestError = 'The browser could not copy the public key.'; requestError = error instanceof Error
? error.message
: 'The browser could not copy the public key.';
} finally { } finally {
busyAction = null; busyAction = null;
} }
@@ -255,20 +299,23 @@
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading"> <section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
<h2 id="runtime-trust-heading">Workspace trust</h2> <h2 id="runtime-trust-heading">Workspace trust</h2>
{#if trust.public_key} {#if trust.status !== 'unconfigured'}
<div class="runtime-public-key-actions"> <div class="runtime-public-key-actions">
<button type="button" class="secondary" onclick={() => revealPublicKey = !revealPublicKey}> <button
{revealPublicKey ? 'Hide public key' : 'Reveal public key'} type="button"
class="secondary"
disabled={busyAction !== null}
onclick={togglePublicKeyReveal}
>
{busyAction === 'reveal' ? 'Loading…' : showPublicKey ? 'Hide public key' : 'Reveal public key'}
</button> </button>
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}> <button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'} {busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
</button> </button>
</div> </div>
{#if revealPublicKey} {#if showPublicKey && revealedPublicKey}
<pre class="runtime-public-key"><code>{trust.public_key}</code></pre> <pre class="runtime-public-key"><code>{revealedPublicKey}</code></pre>
{/if} {/if}
{:else if trust.status !== 'unconfigured'}
<p class="section-state">The public key was not included in this authorized response.</p>
{/if} {/if}
<form class="runtime-trust-form" onsubmit={saveTrustKey}> <form class="runtime-trust-form" onsubmit={saveTrustKey}>
@@ -324,11 +371,25 @@
<div> <div>
<strong>Revoke Workspace trust</strong> <strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p> <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> </div>
<button <button
type="button" type="button"
class="danger" class="danger"
disabled={busyAction !== null || trust.status !== 'active'} disabled={
busyAction !== null ||
trust.status !== 'active' ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
}
onclick={revokeTrust} onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button> >{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div> </div>
@@ -110,6 +110,8 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Revoke Workspace trust", "Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"RuntimeTrustConflictError", "RuntimeTrustConflictError",
"revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()", "await reloadAuthority()",
"busyAction !== null", "busyAction !== null",
"Workdirs", "Workdirs",
+30 -4
View File
@@ -4,10 +4,12 @@ declare const Deno: {
import { import {
parseRuntimeTrustConflict, parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList, parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey, putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
} from "../src/lib/workspace/api/runtime-management.ts"; } from "../src/lib/workspace/api/runtime-management.ts";
@@ -62,7 +64,6 @@ function detail() {
endpoint: "https://runtime.example.test", endpoint: "https://runtime.example.test",
trust_key: { trust_key: {
status: "active", status: "active",
public_key: "ssh-ed25519 AAAA-test",
fingerprint: "SHA256:current", fingerprint: "SHA256:current",
revision: 3, revision: 3,
created_at: "2026-09-01T12:00:00Z", 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", () => { 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( assertThrows(
() => parseWorkspaceRuntimeDetail(largeKey), () =>
parseRuntimeTrustKeyRevealResponse({
public_key: "x".repeat(16 * 1024 + 1),
}),
"must be at most 16384 UTF-8 bytes", "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 () => { Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => {
const fingerprint = await previewRuntimePublicKeyFingerprint( const fingerprint = await previewRuntimePublicKeyFingerprint(
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",