fix: gate runtime key reveal and revoke confirmation
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-18
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user