diff --git a/Cargo.lock b/Cargo.lock index 4640a34f..17c9eb51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5086,8 +5086,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.29.0", + "webpki-roots 0.26.11", ] [[package]] @@ -5395,6 +5399,8 @@ dependencies = [ "httparse", "log", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.18", ] @@ -6133,6 +6139,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 99d72538..030115b2 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -12,10 +12,10 @@ use workspace_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, - RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse, - TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, - WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, + ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, + RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, + WorkspaceRuntimeResource, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient { )) } - pub fn put_runtime_trust_key( - &self, - runtime_id: &str, - request: &PutRuntimeTrustKeyRequest, - ) -> Result { - self.send_json( - Method::PUT, - &format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)), - Some(request), - ) - } - pub fn revoke_runtime_trust_key( &self, runtime_id: &str, diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index 8bd23773..33f6c72b 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -14,6 +14,7 @@ fs-operation.workspace = true manifest.workspace = true reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true sha2.workspace = true tempfile.workspace = true thiserror.workspace = true diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index f89b608b..cd02e19b 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -293,7 +293,12 @@ mod client { /// implementations can mint short-lived capability tokens without making a /// Worker-bound session expire with the token used to open it. pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync { - fn bearer_token(&self) -> Result; + fn bearer_token( + &self, + method: &str, + path_and_query: &str, + body: &[u8], + ) -> Result; } struct FixedBearerToken(Arc); @@ -305,7 +310,12 @@ mod client { } impl WorkdirHttpAuthorization for FixedBearerToken { - fn bearer_token(&self) -> Result { + fn bearer_token( + &self, + _method: &str, + _path_and_query: &str, + _body: &[u8], + ) -> Result { Ok(self.0.to_string()) } } @@ -354,10 +364,14 @@ mod client { &base_url, &["v1", "working-directories", workdir_id.as_str(), "sessions"], )?; + let body = serde_json::to_vec(&request) + .map_err(|error| WorkdirError::Unavailable(error.to_string()))?; + let token = authorization.bearer_token("POST", url.path(), &body)?; let response = client .post(url) - .bearer_auth(authorization.bearer_token()?) - .json(&request) + .bearer_auth(token) + .header("content-type", "application/json") + .body(body) .send() .await .map_err(http_unavailable)?; @@ -401,11 +415,15 @@ mod client { ], )?; let operation = WorkdirSessionOperationRequest { operation }; + let body = serde_json::to_vec(&operation) + .map_err(|error| WorkdirError::Unavailable(error.to_string()))?; + let token = self.authorization.bearer_token("POST", url.path(), &body)?; let response = self .client .post(url) - .bearer_auth(self.authorization.bearer_token()?) - .json(&operation) + .bearer_auth(token) + .header("content-type", "application/json") + .body(body) .send() .await .map_err(http_unavailable)?; @@ -543,10 +561,11 @@ mod client { &self.base_url, &["v1", "workdir-sessions", self.session_id.as_str()], )?; + let token = self.authorization.bearer_token("DELETE", url.path(), &[])?; let response = self .client .delete(url) - .bearer_auth(self.authorization.bearer_token()?) + .bearer_auth(token) .send() .await .map_err(http_unavailable)?; diff --git a/crates/worker-runtime/src/auth.rs b/crates/worker-runtime/src/auth.rs index 05279ea5..9e7a8948 100644 --- a/crates/worker-runtime/src/auth.rs +++ b/crates/worker-runtime/src/auth.rs @@ -2,6 +2,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::rand::{SecureRandom, SystemRandom}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fmt; @@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:"; const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:"; -const TOKEN_PREFIX: &str = "yoi-cap-v1"; -const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1."; pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof"; const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1"; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; @@ -68,6 +67,74 @@ pub enum RuntimeAuthError { WrongMutationTarget, } +pub(crate) struct SignedJsonToken { + pub payload: String, + pub signature: Vec, + pub claims: T, +} + +pub(crate) fn sign_json_token( + token_prefix: &str, + signing_input_prefix: &str, + signing_key: &Ed25519KeyPair, + claims: &T, +) -> Result { + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?); + let signing_input = format!("{signing_input_prefix}{payload}"); + let signature = signing_key.sign(signing_input.as_bytes()); + Ok(format!( + "{token_prefix}.{payload}.{}", + URL_SAFE_NO_PAD.encode(signature.as_ref()) + )) +} + +pub(crate) fn decode_signed_json_token( + token: &str, + expected_prefix: &str, +) -> Result, RuntimeAuthError> { + let (prefix, payload, signature) = split_three_part_token(token)?; + if prefix != expected_prefix { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + let signature = URL_SAFE_NO_PAD.decode(signature)?; + let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?; + Ok(SignedJsonToken { + payload: payload.to_string(), + signature, + claims, + }) +} + +pub(crate) fn verify_signed_json_token( + signing_input_prefix: &str, + payload: &str, + signature: &[u8], + public_key: &str, +) -> Result<(), RuntimeAuthError> { + let public_key = decode_public_key(public_key)?; + let signing_input = format!("{signing_input_prefix}{payload}"); + UnparsedPublicKey::new(&ED25519, public_key) + .verify(signing_input.as_bytes(), signature) + .map_err(|_| RuntimeAuthError::InvalidSignature) +} + +fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> { + let mut parts = token.split('.'); + let prefix = parts.next().unwrap_or_default(); + let payload = parts.next().unwrap_or_default(); + let signature = parts.next().unwrap_or_default(); + if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + Ok((prefix, payload, signature)) +} + +pub(crate) fn is_request_body_digest(value: &str) -> bool { + URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeIdentityMaterial { pub identity_id: String, @@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TrustedServerKey { - pub server_id: String, - pub public_key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_name: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RuntimeHttpAuthConfig { - pub runtime_id: String, - #[serde(default)] - pub trusted_servers: Vec, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeAuthContext { pub server_id: String, @@ -119,122 +171,6 @@ pub struct RuntimeAuthContext { pub expires_at: u64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityClaims { - pub iss: String, - pub aud: String, - pub workspace_id: String, - pub permissions: Vec, - pub exp: u64, - pub jti: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CapabilityTokenSigner { - server_id: String, - private_key: String, -} - -impl CapabilityTokenSigner { - pub fn new(server_id: impl Into, private_key: impl Into) -> Self { - Self { - server_id: server_id.into(), - private_key: private_key.into(), - } - } - - pub fn server_id(&self) -> &str { - &self.server_id - } - - pub fn sign(&self, claims: &CapabilityClaims) -> Result { - if claims.iss != self.server_id { - return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone())); - } - let private = decode_private_key(&self.private_key)?; - let pair = Ed25519KeyPair::from_pkcs8(&private) - .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; - let payload = serde_json::to_vec(claims)?; - let payload = URL_SAFE_NO_PAD.encode(payload); - let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}"); - let signature = pair.sign(signing_input.as_bytes()); - Ok(format!( - "{TOKEN_PREFIX}.{payload}.{}", - URL_SAFE_NO_PAD.encode(signature.as_ref()) - )) - } -} - -pub fn capability_claims( - server_id: impl Into, - runtime_id: impl Into, - workspace_id: impl Into, - permissions: Vec, - ttl_seconds: u64, -) -> Result { - let exp = unix_now_seconds().saturating_add(ttl_seconds); - Ok(CapabilityClaims { - iss: server_id.into(), - aud: runtime_id.into(), - workspace_id: workspace_id.into(), - permissions, - exp, - jti: new_token_id()?, - }) -} - -pub fn verify_capability_token( - config: &RuntimeHttpAuthConfig, - token: &str, - required_permission: Option<&str>, - now_seconds: u64, -) -> Result { - let (payload, signature) = split_token(token)?; - let claims_json = URL_SAFE_NO_PAD.decode(payload)?; - let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?; - let Some(server) = config - .trusted_servers - .iter() - .find(|server| server.server_id == claims.iss) - else { - return Err(RuntimeAuthError::UnknownIssuer(claims.iss)); - }; - let public_key = decode_public_key(&server.public_key)?; - let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}"); - UnparsedPublicKey::new(&ED25519, public_key) - .verify(signing_input.as_bytes(), &signature) - .map_err(|_| RuntimeAuthError::InvalidSignature)?; - - if claims.aud != config.runtime_id { - return Err(RuntimeAuthError::WrongAudience { - expected: config.runtime_id.clone(), - actual: claims.aud, - }); - } - if claims.exp < now_seconds { - return Err(RuntimeAuthError::Expired); - } - if claims.workspace_id.trim().is_empty() { - return Err(RuntimeAuthError::MissingWorkspaceScope); - } - if let Some(required) = required_permission { - if !claims - .permissions - .iter() - .any(|permission| permission == required) - { - return Err(RuntimeAuthError::MissingPermission(required.to_string())); - } - } - Ok(RuntimeAuthContext { - server_id: claims.iss, - workspace_id: claims.workspace_id, - permissions: claims.permissions, - token_id: claims.jti, - expires_at: claims.exp, - }) -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeRequestSourceClaims { pub iss: String, @@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner { exp: now_unix.saturating_add(ttl_seconds), jti: new_token_id()?, }; - let payload = serde_json::to_vec(&claims)?; - let payload = URL_SAFE_NO_PAD.encode(payload); - let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); let private = decode_private_key(&self.private_key)?; let key_pair = Ed25519KeyPair::from_pkcs8(&private) .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; - let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); - Ok(format!( - "{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" - )) + sign_json_token( + RUNTIME_REQUEST_SOURCE_PROOF_PREFIX, + RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX, + &key_pair, + &claims, + ) } } pub fn decode_runtime_request_source_claims( proof: &str, ) -> Result { - let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; - if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { - return Err(RuntimeAuthError::InvalidTokenFormat); - } - let payload = URL_SAFE_NO_PAD.decode(payload)?; - serde_json::from_slice(&payload).map_err(RuntimeAuthError::from) + Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims) } pub fn verify_runtime_request_source( @@ -352,17 +282,17 @@ pub fn verify_runtime_request_source( public_key: &str, expected: &RuntimeRequestSourceExpectation<'_>, ) -> Result { - let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; - if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { - return Err(RuntimeAuthError::InvalidTokenFormat); - } - let signature = URL_SAFE_NO_PAD.decode(signature)?; - let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); - let public_key = decode_public_key(public_key)?; - UnparsedPublicKey::new(&ED25519, public_key) - .verify(signing_input.as_bytes(), &signature) - .map_err(|_| RuntimeAuthError::InvalidSignature)?; - let claims = decode_runtime_request_source_claims(proof)?; + let signed = decode_signed_json_token::( + proof, + RUNTIME_REQUEST_SOURCE_PROOF_PREFIX, + )?; + verify_signed_json_token( + RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX, + &signed.payload, + &signed.signature, + public_key, + )?; + let claims = signed.claims; if claims.iss != expected.identity_id || claims.aud != expected.audience || claims.workspace_id != expected.workspace_id @@ -380,17 +310,6 @@ pub fn verify_runtime_request_source( Ok(claims) } -fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> { - let mut parts = proof.split('.'); - let prefix = parts.next().unwrap_or_default(); - let payload = parts.next().unwrap_or_default(); - let signature = parts.next().unwrap_or_default(); - if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() { - return Err(RuntimeAuthError::InvalidTokenFormat); - } - Ok((prefix, payload, signature)) -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerMutationSourceClaims { pub iss: String, @@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec), Ru } } -fn split_token(token: &str) -> Result<(&str, Vec), RuntimeAuthError> { - let mut parts = token.split('.'); - match (parts.next(), parts.next(), parts.next(), parts.next()) { - (Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => { - Ok((payload, URL_SAFE_NO_PAD.decode(signature)?)) - } - _ => Err(RuntimeAuthError::InvalidTokenFormat), - } -} - pub fn encode_public_key(bytes: &[u8]) -> String { format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) } @@ -851,46 +760,4 @@ mod tests { Err(RuntimeAuthError::Expired) )); } - - #[test] - fn capability_token_verifies_signature_audience_expiry_and_permission() { - let server = RuntimeIdentityMaterial::generate("server-main").unwrap(); - let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key); - let claims = CapabilityClaims { - iss: "server-main".to_string(), - aud: "runtime-main".to_string(), - workspace_id: "workspace-a".to_string(), - permissions: vec!["workers:list".to_string()], - exp: 100, - jti: "token-1".to_string(), - }; - let token = signer.sign(&claims).unwrap(); - let auth = RuntimeHttpAuthConfig { - runtime_id: "runtime-main".to_string(), - trusted_servers: vec![TrustedServerKey { - server_id: "server-main".to_string(), - public_key: server.public_key.clone(), - display_name: None, - }], - }; - - let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap(); - assert_eq!(context.workspace_id, "workspace-a"); - assert!(matches!( - verify_capability_token(&auth, &token, Some("workers:create"), 99), - Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create" - )); - assert!(matches!( - verify_capability_token(&auth, &token, Some("workers:list"), 101), - Err(RuntimeAuthError::Expired) - )); - let wrong_audience = RuntimeHttpAuthConfig { - runtime_id: "other-runtime".to_string(), - trusted_servers: auth.trusted_servers.clone(), - }; - assert!(matches!( - verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99), - Err(RuntimeAuthError::WrongAudience { .. }) - )); - } } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index c8c13013..bed1b816 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -6,10 +6,7 @@ //! Runtime process directly; a backend is expected to own any browser-facing //! credentials, registration, and policy. -use crate::auth::{ - RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, new_token_id, unix_now_seconds, - verify_capability_token, -}; +use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds}; use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, @@ -27,6 +24,15 @@ use crate::retention::{ }; #[cfg(feature = "ws-server")] use crate::runtime::RuntimeSubscriptionRecvError; +use crate::workspace_issuer::{ + RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH, + WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION, + WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier, + WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority, + WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt, + WorkspaceRuntimeVerificationRecord, WorkspaceRuntimeVerificationResponse, + inspect_workspace_capability_claims, workspace_request_body_digest, +}; use crate::{Runtime, RuntimeWorkspaceScope}; use axum::body::{Body, Bytes}; use axum::extract::rejection::{JsonRejection, QueryRejection}; @@ -86,11 +92,9 @@ pub struct RuntimeHttpServerConfig { pub display_name: Option, /// v0 store selection for the Runtime process. pub store: RuntimeHttpStoreSelection, - /// Minimal local bearer token placeholder for backend-to-Runtime calls. + /// Minimal local bearer token for explicitly local Runtime calls. /// This is not a browser-facing credential model. pub local_token: Option, - /// Optional signed Server-to-Runtime capability token authority. - pub auth: Option, } impl Default for RuntimeHttpServerConfig { @@ -100,7 +104,6 @@ impl Default for RuntimeHttpServerConfig { display_name: None, store: RuntimeHttpStoreSelection::Memory, local_token: None, - auth: None, } } } @@ -143,19 +146,15 @@ pub async fn serve_runtime_http( Ok(()) } -/// Serve an existing Runtime on a pre-bound listener with signed capability-token auth. -pub async fn serve_runtime_http_with_auth( +pub async fn serve_runtime_http_with_workspace_auth( runtime: Runtime, listener: TcpListener, local_token: Option, - auth: Option, + workspace_auth: WorkspaceRuntimeHttpAuth, ) -> Result<(), RuntimeHttpServerError> { - if local_token.is_none() && auth.is_none() { - return Err(RuntimeHttpServerError::AuthRequired); - } axum::serve( listener, - runtime_http_router_with_optional_auth(runtime, local_token, auth), + runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)), ) .await?; Ok(()) @@ -170,29 +169,36 @@ pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router { runtime_http_router_with_optional_auth(runtime, Some(local_token), None) } -/// Build the REST router for an existing Runtime with signed capability-token auth. -pub fn runtime_http_router_with_auth( +pub fn runtime_http_router_with_workspace_auth( runtime: Runtime, local_token: Option, - auth: RuntimeHttpAuthConfig, + workspace_auth: WorkspaceRuntimeHttpAuth, ) -> Router { - runtime_http_router_with_optional_auth(runtime, local_token, Some(auth)) + runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)) } fn runtime_http_router_with_optional_auth( runtime: Runtime, local_token: Option, - auth: Option, + workspace_auth: Option, ) -> Router { let state = RuntimeHttpState { runtime, local_token: local_token.map(Arc::::from), - auth: auth.map(Arc::new), + workspace_auth: workspace_auth.map(Arc::new), workdir_sessions: Arc::new(Mutex::new(HashMap::new())), }; let router = Router::new() .route("/v1/ping", get(get_runtime_ping)) + .route( + WORKSPACE_VERIFICATION_CHALLENGE_PATH, + post(post_workspace_verification_challenge), + ) + .route( + WORKSPACE_VERIFICATION_ACK_PATH, + post(post_workspace_verification_acknowledgement), + ) .route("/v1/runtime", get(get_runtime)) .route( "/v1/config-bundles", @@ -285,10 +291,17 @@ pub const MAX_WORKER_FILE_UPLOAD_BYTES: usize = struct RuntimeHttpState { runtime: Runtime, local_token: Option>, - auth: Option>, + workspace_auth: Option>, workdir_sessions: Arc>>, } +#[derive(Clone, Debug)] +pub struct WorkspaceRuntimeHttpAuth { + pub verifier: WorkspaceCapabilityVerifier, + pub signer: RuntimeVerificationSigner, + pub verifications: Arc, +} + struct RuntimeHttpWorkdirSession { owner: RuntimeWorkspaceScope, session: WorkdirSessionHandle, @@ -475,6 +488,164 @@ struct RuntimeWorkerEventsWsQuery { type RestResult = Result, RuntimeHttpRestError>; +fn unix_now_i64() -> i64 { + i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX) +} + +async fn post_workspace_verification_challenge( + State(state): State, + Extension(verified): Extension, + Json(challenge): Json, +) -> RestResult { + let auth = state.workspace_auth.as_deref().ok_or_else(|| { + RuntimeHttpRestError::new( + StatusCode::NOT_IMPLEMENTED, + "workspace_runtime_verification_unavailable", + "Workspace Runtime verification is not configured", + ) + })?; + if challenge.workspace_id != verified.workspace_id + || challenge.runtime_id != verified.runtime_id + || challenge.binding_revision != verified.binding_revision + || challenge.workspace_key_id != verified.issuer_key_id + || challenge.workspace_identity_revision != verified.issuer_identity_revision + || challenge.workspace_trust_generation != verified.trust_generation + || challenge.runtime_id != auth.signer.runtime_id() + || challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() + { + return Err(RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "workspace_runtime_verification_rejected", + "Workspace Runtime verification challenge does not match authenticated authority", + )); + } + let response = auth + .signer + .sign_response(&challenge, uuid::Uuid::now_v7().to_string(), unix_now_i64()) + .map_err(|error| { + RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "workspace_runtime_verification_rejected", + error.to_string(), + ) + })?; + Ok(Json(response)) +} + +async fn post_workspace_verification_acknowledgement( + State(state): State, + Extension(verified): Extension, + Json(acknowledgement): Json, +) -> RestResult { + let auth = state.workspace_auth.as_deref().ok_or_else(|| { + RuntimeHttpRestError::new( + StatusCode::NOT_IMPLEMENTED, + "workspace_runtime_verification_unavailable", + "Workspace Runtime verification is not configured", + ) + })?; + if acknowledgement.workspace_id != verified.workspace_id + || acknowledgement.runtime_id != verified.runtime_id + || acknowledgement.binding_revision != verified.binding_revision + || acknowledgement.workspace_key_id != verified.issuer_key_id + || acknowledgement.workspace_identity_revision != verified.issuer_identity_revision + || acknowledgement.workspace_trust_generation != verified.trust_generation + || acknowledgement.runtime_id != auth.signer.runtime_id() + || acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() + || acknowledgement.expires_at <= unix_now_i64() + || acknowledgement.binding_revision == 0 + || acknowledgement.runtime_identity_revision == 0 + || acknowledgement.workspace_identity_revision == 0 + || acknowledgement.workspace_trust_generation == 0 + || acknowledgement.workspace_nonce.is_empty() + || acknowledgement.runtime_nonce.is_empty() + || acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len() + { + return Err(RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "workspace_runtime_verification_rejected", + "Workspace Runtime verification acknowledgement is invalid", + )); + } + let challenge = WorkspaceRuntimeVerificationChallenge { + challenge_id: acknowledgement.challenge_id.clone(), + workspace_id: acknowledgement.workspace_id.clone(), + runtime_id: acknowledgement.runtime_id.clone(), + binding_revision: acknowledgement.binding_revision, + workspace_key_id: acknowledgement.workspace_key_id.clone(), + workspace_identity_revision: acknowledgement.workspace_identity_revision, + workspace_trust_generation: acknowledgement.workspace_trust_generation, + runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: acknowledgement.runtime_identity_revision, + workspace_nonce: acknowledgement.workspace_nonce.clone(), + expires_at: acknowledgement.expires_at, + }; + let response = acknowledgement.response.clone(); + if response.runtime_nonce != acknowledgement.runtime_nonce + || response.workspace_nonce != acknowledgement.workspace_nonce + || response.response_proof.is_empty() + { + return Err(RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "workspace_runtime_verification_rejected", + "Workspace Runtime verification acknowledgement does not match its response", + )); + } + let response_bytes = serde_json::to_vec(&response).map_err(|_| { + RuntimeHttpRestError::new( + StatusCode::BAD_REQUEST, + "workspace_runtime_verification_rejected", + "Workspace Runtime verification response could not be canonicalized", + ) + })?; + if workspace_request_body_digest(&response_bytes) != acknowledgement.response_digest { + return Err(RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "workspace_runtime_verification_rejected", + "Workspace Runtime verification acknowledgement has the wrong response digest", + )); + } + crate::workspace_issuer::verify_runtime_verification_response( + &response, + &challenge, + auth.signer.public_key(), + unix_now_i64(), + ) + .map_err(|error| { + RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "workspace_runtime_verification_rejected", + error.to_string(), + ) + })?; + auth.verifications + .record(WorkspaceRuntimeVerificationRecord { + workspace_id: acknowledgement.workspace_id.clone(), + runtime_id: acknowledgement.runtime_id.clone(), + binding_revision: acknowledgement.binding_revision, + workspace_key_id: acknowledgement.workspace_key_id.clone(), + workspace_identity_revision: acknowledgement.workspace_identity_revision, + workspace_trust_generation: acknowledgement.workspace_trust_generation, + runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: acknowledgement.runtime_identity_revision, + verified_at: unix_now_i64(), + }) + .map_err(|error| { + RuntimeHttpRestError::new( + StatusCode::SERVICE_UNAVAILABLE, + "workspace_runtime_verification_unavailable", + error.to_string(), + ) + })?; + Ok(Json(WorkspaceRuntimeVerificationReceipt { + challenge_id: acknowledgement.challenge_id, + workspace_id: acknowledgement.workspace_id, + runtime_id: acknowledgement.runtime_id, + binding_revision: acknowledgement.binding_revision, + accepted_at: unix_now_i64(), + })) +} + async fn get_runtime_ping( State(state): State, Extension(auth): Extension, @@ -500,9 +671,9 @@ async fn get_runtime_ping( )); } let runtime_id = state - .auth + .workspace_auth .as_ref() - .map(|config| config.runtime_id.trim()) + .map(|auth| auth.signer.runtime_id().trim()) .filter(|runtime_id| !runtime_id.is_empty()) .ok_or_else(|| { RuntimeHttpRestError::new( @@ -1797,35 +1968,132 @@ async fn require_runtime_auth( .headers() .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")); + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_owned); - if let Some(auth) = state.auth.as_deref() { - let Some(token) = supplied else { - return RuntimeHttpRestError::new( - StatusCode::UNAUTHORIZED, - "unauthorized", - "missing Runtime capability bearer token", - ) - .into_response(); + if let Some(workspace_auth) = state.workspace_auth.as_deref() + && let Some(token) = supplied.as_deref() + && let Ok(claims) = inspect_workspace_capability_claims(token) + { + let method = request.method().as_str().to_string(); + let path_and_query = request + .uri() + .path_and_query() + .map_or_else(|| request.uri().path().to_string(), ToString::to_string); + let required_permission = + workspace_runtime_operation(request.method(), request.uri().path()); + let expected_worker_id = worker_id_from_runtime_path(request.uri().path()); + let (parts, body) = request.into_parts(); + let body = match axum::body::to_bytes(body, 8 * 1024 * 1024).await { + Ok(body) => body, + Err(_) => { + return RuntimeHttpRestError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "request_body_too_large", + "Runtime request body exceeds the verification limit", + ) + .into_response(); + } }; - match verify_capability_token( - auth, - token, - required_runtime_permission(request.method(), request.uri().path()), - unix_now_seconds(), - ) { - Ok(context) => { - request.extensions_mut().insert(context); + let body_digest = workspace_request_body_digest(&body); + let expected = WorkspaceCapabilityExpectation { + workspace_id: &claims.issuer_workspace_id, + binding_revision: claims.binding_revision, + runtime_id: workspace_auth.signer.runtime_id(), + worker_id: expected_worker_id.as_deref(), + operation: required_permission, + method: &method, + path_and_query: &path_and_query, + body_digest: &body_digest, + now_unix: unix_now_i64(), + }; + match workspace_auth.verifier.verify(token, &expected) { + Ok(verified) => { + let is_verification = path_and_query == WORKSPACE_VERIFICATION_CHALLENGE_PATH + || path_and_query == WORKSPACE_VERIFICATION_ACK_PATH; + if !is_verification { + let record = match workspace_auth + .verifications + .get(&verified.workspace_id, workspace_auth.signer.runtime_id()) + { + Ok(Some(record)) => record, + Ok(None) => { + return RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "workspace_runtime_verification_required", + "Workspace Runtime binding has not completed signed verification", + ) + .into_response(); + } + Err(error) => { + return RuntimeHttpRestError::new( + StatusCode::SERVICE_UNAVAILABLE, + "workspace_runtime_verification_unavailable", + error.to_string(), + ) + .into_response(); + } + }; + if record.binding_revision != verified.binding_revision + || record.workspace_key_id != verified.issuer_key_id + || record.workspace_identity_revision != verified.issuer_identity_revision + || record.workspace_trust_generation != verified.trust_generation + || record.runtime_public_key_fingerprint + != workspace_auth.signer.public_key_fingerprint() + || record.runtime_identity_revision == 0 + { + return RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "workspace_runtime_verification_stale", + "Workspace Runtime verification does not match current request authority", + ) + .into_response(); + } + } + request = Request::from_parts(parts, Body::from(body)); + request.extensions_mut().insert(verified.clone()); + request.extensions_mut().insert(RuntimeAuthContext { + server_id: verified.issuer, + workspace_id: verified.workspace_id, + permissions: vec![required_permission.to_string()], + token_id: verified.token_id, + expires_at: u64::try_from(verified.expires_at).unwrap_or(0), + }); return next.run(request).await; } Err(error) => { - return runtime_auth_error_response(error).into_response(); + return RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "unauthorized", + format!("invalid Workspace capability token: {error}"), + ) + .into_response(); } } } + let workspace_bootstrap_request = request.method() == Method::POST + && matches!( + request.uri().path(), + WORKSPACE_VERIFICATION_CHALLENGE_PATH | WORKSPACE_VERIFICATION_ACK_PATH + ); + if state.workspace_auth.is_some() && !workspace_bootstrap_request { + let local_token_matches = state + .local_token + .as_deref() + .is_some_and(|expected| supplied.as_deref() == Some(expected)); + if !local_token_matches { + return RuntimeHttpRestError::new( + StatusCode::UNAUTHORIZED, + "unauthorized", + "missing or invalid Workspace capability bearer token", + ) + .into_response(); + } + } + if let Some(expected) = state.local_token.as_deref() { - if supplied != Some(expected) { + if supplied.as_deref() != Some(expected) { return RuntimeHttpRestError::new( StatusCode::UNAUTHORIZED, "unauthorized", @@ -1844,32 +2112,12 @@ async fn require_runtime_auth( next.run(request).await } -fn runtime_auth_error_response(error: RuntimeAuthError) -> RuntimeHttpRestError { - match error { - RuntimeAuthError::MissingPermission(permission) => RuntimeHttpRestError::new( - StatusCode::FORBIDDEN, - "forbidden", - format!("Runtime capability token is missing required permission `{permission}`"), - ), - RuntimeAuthError::MissingWorkspaceScope => RuntimeHttpRestError::new( - StatusCode::FORBIDDEN, - "workspace_scope_required", - "Runtime capability token is missing workspace scope", - ), - other => RuntimeHttpRestError::new( - StatusCode::UNAUTHORIZED, - "unauthorized", - format!("invalid Runtime capability token: {other}"), - ), - } -} - fn auth_workspace_scope( state: &RuntimeHttpState, auth: Option<&Extension>, ) -> Result, RuntimeHttpRestError> { let Some(Extension(context)) = auth else { - if state.auth.is_some() || state.local_token.is_some() { + if state.workspace_auth.is_some() || state.local_token.is_some() { return Err(RuntimeHttpRestError::new( StatusCode::FORBIDDEN, "workspace_scope_required", @@ -1897,6 +2145,21 @@ fn auth_workspace_scope( Ok(Some(RuntimeWorkspaceScope::new(workspace_id, server_id))) } +fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str { + if (path == WORKSPACE_VERIFICATION_CHALLENGE_PATH || path == WORKSPACE_VERIFICATION_ACK_PATH) + && *method == Method::POST + { + return WORKSPACE_VERIFICATION_OPERATION; + } + required_runtime_permission(method, path).unwrap_or("runtime:read") +} + +fn worker_id_from_runtime_path(path: &str) -> Option { + let rest = path.strip_prefix("/v1/workers/")?; + let worker_id = rest.split('/').next()?; + (!worker_id.is_empty()).then(|| worker_id.to_string()) +} + fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> { if path == "/v1/ping" && *method == Method::GET { return Some(RUNTIME_PING_PERMISSION); @@ -2186,7 +2449,7 @@ fn code_for_runtime_error(error: &RuntimeError) -> String { pub enum RuntimeHttpServerError { #[error(transparent)] Runtime(#[from] RuntimeError), - #[error("Runtime HTTP server requires capability-token auth or a local bearer token")] + #[error("Runtime HTTP server requires Workspace issuer auth or a local bearer token")] AuthRequired, #[error("Runtime HTTP server I/O failed: {0}")] Io(#[from] std::io::Error), @@ -2195,10 +2458,7 @@ pub enum RuntimeHttpServerError { #[cfg(test)] mod tests { use super::*; - use crate::auth::{ - CapabilityTokenSigner, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, - capability_claims, - }; + use crate::auth::RuntimeIdentityMaterial; use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef}; use crate::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, @@ -2209,9 +2469,16 @@ mod tests { WorkerExecutionSpawnResult, }; use crate::management::RuntimeOptions; + use crate::workspace_issuer::{ + InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority, + WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, + WorkspaceIssuerTrustState, issue_workspace_capability_token, + verify_runtime_verification_response, + }; use axum::body::to_bytes; use axum::http::Method; use manifest::{Scope, SharedScope}; + use sha2::Digest as _; use tower::ServiceExt; use workdir::{ GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath, @@ -2219,79 +2486,204 @@ mod tests { }; #[tokio::test] - async fn ping_requires_scoped_permission_and_returns_versioned_identity() { + async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() { let runtime = Runtime::new_memory(); - let (auth, signer) = auth_config_and_signer(); - let app = runtime_http_router_with_auth(runtime, None, auth); + let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap(); + let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + let workspace_public_key = + crate::auth::decode_public_key(&workspace_identity.public_key).unwrap(); + let workspace_fingerprint = format!( + "sha256:{}", + crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(workspace_public_key)) + ); + let runtime_public_key = + crate::auth::decode_public_key(&runtime_identity.public_key).unwrap(); + let runtime_fingerprint = format!( + "sha256:{}", + crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(runtime_public_key)) + ); + let verifier = WorkspaceCapabilityVerifier::new( + vec![WorkspaceIssuerTrustRecord { + workspace_id: "workspace-a".to_string(), + backend_url: "https://backend.test".to_string(), + key_id: "workspace-key".to_string(), + algorithm: "ed25519".to_string(), + public_key: workspace_identity.public_key.clone(), + public_key_fingerprint: workspace_fingerprint, + identity_revision: 1, + trust_generation: 1, + state: WorkspaceIssuerTrustState::Active, + registered_at_unix: 1, + updated_at_unix: 1, + }], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let app = runtime_http_router_with_workspace_auth( + runtime, + None, + WorkspaceRuntimeHttpAuth { + verifier, + signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(), + verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()), + }, + ); + let challenge = WorkspaceRuntimeVerificationChallenge { + challenge_id: "challenge-1".to_string(), + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-test".to_string(), + binding_revision: 4, + workspace_key_id: "workspace-key".to_string(), + workspace_identity_revision: 1, + workspace_trust_generation: 1, + runtime_public_key_fingerprint: runtime_fingerprint, + runtime_identity_revision: 1, + workspace_nonce: "workspace-nonce".to_string(), + expires_at: unix_now_i64() + 60, + }; + let body = serde_json::to_vec(&challenge).unwrap(); + let claims = WorkspaceCapabilityClaims { + issuer: "https://backend.test".to_string(), + issuer_workspace_id: "workspace-a".to_string(), + issuer_key_id: "workspace-key".to_string(), + issuer_identity_revision: 1, + trust_generation: 1, + binding_revision: 4, + runtime_id: "runtime-test".to_string(), + worker_id: None, + operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), + method: "POST".to_string(), + path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.to_string(), + body_digest: workspace_request_body_digest(&body), + iat: unix_now_i64(), + exp: challenge.expires_at, + jti: "challenge-token".to_string(), + }; let token = - token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]); - let request = Request::builder() - .method(Method::GET) - .uri("/v1/ping") - .header(header::AUTHORIZATION, format!("Bearer {token}")) - .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") - .body(Body::empty()) + issue_workspace_capability_token(&workspace_identity.signing_key().unwrap(), &claims) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(WORKSPACE_VERIFICATION_CHALLENGE_PATH) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await .unwrap(); + let status = response.status(); + let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&response_body) + ); + let verification_response = + serde_json::from_slice::(&response_body).unwrap(); + verify_runtime_verification_response( + &verification_response, + &challenge, + &runtime_identity.public_key, + unix_now_i64(), + ) + .unwrap(); - let response = app.clone().oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let acknowledgement = WorkspaceRuntimeVerificationAcknowledgement { + challenge_id: verification_response.challenge_id.clone(), + workspace_id: verification_response.workspace_id.clone(), + runtime_id: verification_response.runtime_id.clone(), + binding_revision: verification_response.binding_revision, + workspace_key_id: verification_response.workspace_key_id.clone(), + workspace_identity_revision: verification_response.workspace_identity_revision, + workspace_trust_generation: verification_response.workspace_trust_generation, + runtime_public_key_fingerprint: verification_response + .runtime_public_key_fingerprint + .clone(), + runtime_identity_revision: verification_response.runtime_identity_revision, + workspace_nonce: verification_response.workspace_nonce.clone(), + runtime_nonce: verification_response.runtime_nonce.clone(), + response_digest: workspace_request_body_digest(&response_body), + response: verification_response.clone(), + expires_at: verification_response.expires_at, + }; + let acknowledgement_body = serde_json::to_vec(&acknowledgement).unwrap(); + let acknowledgement_claims = WorkspaceCapabilityClaims { + path_and_query: WORKSPACE_VERIFICATION_ACK_PATH.to_string(), + body_digest: workspace_request_body_digest(&acknowledgement_body), + jti: "ack-token".to_string(), + ..claims + }; + let acknowledgement_token = issue_workspace_capability_token( + &workspace_identity.signing_key().unwrap(), + &acknowledgement_claims, + ) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(WORKSPACE_VERIFICATION_ACK_PATH) + .header( + header::AUTHORIZATION, + format!("Bearer {acknowledgement_token}"), + ) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(acknowledgement_body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!( - serde_json::from_slice::(&body).unwrap(), - RuntimeHttpPingResponse { - runtime_id: "runtime-test".to_string(), - protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION, + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + + for authorization in [None, Some("Bearer malformed")] { + let mut request = Request::builder() + .method(Method::POST) + .uri("/v1/config-bundles"); + if let Some(authorization) = authorization { + request = request.header(header::AUTHORIZATION, authorization); } - ); - - let wrong_scope_token = - token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]); - let wrong_scope_request = Request::builder() - .method(Method::GET) - .uri("/v1/ping") - .header(header::AUTHORIZATION, format!("Bearer {wrong_scope_token}")) - .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-b") - .body(Body::empty()) - .unwrap(); - assert_eq!( - app.oneshot(wrong_scope_request).await.unwrap().status(), - StatusCode::FORBIDDEN - ); - } - - #[tokio::test] - async fn ping_rejects_token_without_ping_permission() { - let runtime = Runtime::new_memory(); - let (auth, signer) = auth_config_and_signer(); - let app = runtime_http_router_with_auth(runtime, None, auth); - let missing_credential = Request::builder() - .method(Method::GET) - .uri("/v1/ping") - .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") - .body(Body::empty()) - .unwrap(); - assert_eq!( - app.clone() - .oneshot(missing_credential) + let response = app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) .await - .unwrap() - .status(), - StatusCode::UNAUTHORIZED - ); + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } - let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]); - let request = Request::builder() - .method(Method::GET) - .uri("/v1/ping") - .header(header::AUTHORIZATION, format!("Bearer {token}")) - .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") - .body(Body::empty()) + let ping_claims = WorkspaceCapabilityClaims { + operation: RUNTIME_PING_PERMISSION.to_string(), + method: "GET".to_string(), + path_and_query: "/v1/ping".to_string(), + body_digest: workspace_request_body_digest(&[]), + jti: "ping-token".to_string(), + ..acknowledgement_claims + }; + let ping_token = issue_workspace_capability_token( + &workspace_identity.signing_key().unwrap(), + &ping_claims, + ) + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {ping_token}")) + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") + .body(Body::empty()) + .unwrap(), + ) + .await .unwrap(); - - assert_eq!( - app.oneshot(request).await.unwrap().status(), - StatusCode::FORBIDDEN - ); + assert_eq!(response.status(), StatusCode::OK); } #[test] @@ -2391,301 +2783,6 @@ mod tests { .with_computed_digest() } - fn store_coder_test_bundle(runtime: &Runtime) { - runtime - .store_config_bundle(test_bundle(ProfileSelector::Builtin( - "builtin:coder".to_string(), - ))) - .unwrap(); - } - - fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest { - let mut request = task_request(objective); - request.workspace_api = Some(WorkspaceApiRef { - workspace_id: workspace_id.to_string(), - base_url: format!("https://workspace.example/{workspace_id}"), - }); - request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: workspace_id.to_string(), - settings_revision: 1, - language: "English".to_string(), - }); - request - } - - fn auth_config_and_signer() -> (RuntimeHttpAuthConfig, CapabilityTokenSigner) { - let identity = RuntimeIdentityMaterial::generate("server-a").unwrap(); - let signer = CapabilityTokenSigner::new(identity.identity_id.clone(), identity.private_key); - let auth = RuntimeHttpAuthConfig { - runtime_id: "runtime-test".to_string(), - trusted_servers: vec![TrustedServerKey { - server_id: identity.identity_id, - public_key: identity.public_key, - display_name: None, - }], - }; - (auth, signer) - } - - fn auth_config_and_two_signers() -> ( - RuntimeHttpAuthConfig, - CapabilityTokenSigner, - CapabilityTokenSigner, - ) { - let identity_a = RuntimeIdentityMaterial::generate("server-a").unwrap(); - let identity_b = RuntimeIdentityMaterial::generate("server-b").unwrap(); - let signer_a = - CapabilityTokenSigner::new(identity_a.identity_id.clone(), identity_a.private_key); - let signer_b = - CapabilityTokenSigner::new(identity_b.identity_id.clone(), identity_b.private_key); - let auth = RuntimeHttpAuthConfig { - runtime_id: "runtime-test".to_string(), - trusted_servers: vec![ - TrustedServerKey { - server_id: identity_a.identity_id, - public_key: identity_a.public_key, - display_name: None, - }, - TrustedServerKey { - server_id: identity_b.identity_id, - public_key: identity_b.public_key, - display_name: None, - }, - ], - }; - (auth, signer_a, signer_b) - } - - fn token_for_workspace(signer: &CapabilityTokenSigner, workspace_id: &str) -> String { - token_for_workspace_with_permissions( - signer, - workspace_id, - [ - "workers:list", - "workers:create", - "workers:read", - "workers:input", - "workers:stop", - "workers:protocol", - "workers:delete", - "workdirs:operate", - ], - ) - } - - fn token_for_workspace_with_permissions( - signer: &CapabilityTokenSigner, - workspace_id: &str, - permissions: [&str; N], - ) -> String { - let claims = capability_claims( - signer.server_id(), - "runtime-test", - workspace_id, - permissions.into_iter().map(str::to_string).collect(), - 3600, - ) - .unwrap(); - signer.sign(&claims).unwrap() - } - - fn bearer_request( - method: Method, - uri: impl AsRef, - token: &str, - body: impl Into, - ) -> Request { - Request::builder() - .method(method) - .uri(uri.as_ref()) - .header(header::AUTHORIZATION, format!("Bearer {token}")) - .header(header::CONTENT_TYPE, "application/json") - .body(body.into()) - .unwrap() - } - - #[tokio::test] - async fn capability_workspace_scope_filters_list_and_hides_detail() { - let runtime = - Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) - .unwrap(); - store_coder_test_bundle(&runtime); - let (auth, signer) = auth_config_and_signer(); - let token_a = token_for_workspace(&signer, "workspace-a"); - let token_b = token_for_workspace(&signer, "workspace-b"); - let app = runtime_http_router_with_auth(runtime, None, auth); - - let create_a = scoped_task_request("a", "workspace-a"); - let response = app - .clone() - .oneshot(bearer_request( - Method::POST, - "/v1/workers", - &token_a, - serde_json::to_vec(&create_a).unwrap(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let worker_a: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(worker_a.worker.workspace_id.as_deref(), Some("workspace-a")); - - let create_b = scoped_task_request("b", "workspace-b"); - let response = app - .clone() - .oneshot(bearer_request( - Method::POST, - "/v1/workers", - &token_b, - serde_json::to_vec(&create_b).unwrap(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let worker_b: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(worker_b.worker.workspace_id.as_deref(), Some("workspace-b")); - - let response = app - .clone() - .oneshot(bearer_request( - Method::GET, - "/v1/workers", - &token_a, - Body::empty(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let workers: RuntimeHttpWorkersResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(workers.workers.len(), 1); - assert_eq!(workers.workers[0].worker_ref, worker_a.worker.worker_ref); - assert_eq!( - workers.workers[0].workspace_id.as_deref(), - Some("workspace-a") - ); - - let response = app - .oneshot(bearer_request( - Method::GET, - format!("/v1/workers/{}", worker_b.worker.worker_id), - &token_a, - Body::empty(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn capability_workspace_owner_binding_rejects_other_trusted_server() { - let runtime = - Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) - .unwrap(); - store_coder_test_bundle(&runtime); - let (auth, signer_a, signer_b) = auth_config_and_two_signers(); - let token_a = token_for_workspace(&signer_a, "workspace-a"); - let token_b = token_for_workspace(&signer_b, "workspace-a"); - let app = runtime_http_router_with_auth(runtime, None, auth); - - let create_a = scoped_task_request("a", "workspace-a"); - let response = app - .clone() - .oneshot(bearer_request( - Method::POST, - "/v1/workers", - &token_a, - serde_json::to_vec(&create_a).unwrap(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let create_b = scoped_task_request("b", "workspace-a"); - let response = app - .oneshot(bearer_request( - Method::POST, - "/v1/workers", - &token_b, - serde_json::to_vec(&create_b).unwrap(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn capability_token_without_workspace_scope_is_forbidden() { - let runtime = - Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) - .unwrap(); - let (auth, signer) = auth_config_and_signer(); - let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]); - let app = runtime_http_router_with_auth(runtime, None, auth); - - let response = app - .oneshot(bearer_request( - Method::GET, - "/v1/workers", - &token, - Body::empty(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn capability_token_without_worker_permission_is_forbidden() { - let runtime = - Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) - .unwrap(); - let (auth, signer) = auth_config_and_signer(); - let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]); - let app = runtime_http_router_with_auth(runtime, None, auth); - let create = scoped_task_request("a", "workspace-a"); - - let response = app - .oneshot(bearer_request( - Method::POST, - "/v1/workers", - &token, - serde_json::to_vec(&create).unwrap(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn capability_token_without_workdir_permission_is_forbidden() { - let runtime = - Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) - .unwrap(); - let (auth, signer) = auth_config_and_signer(); - let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]); - let app = runtime_http_router_with_auth(runtime, None, auth); - - let response = app - .oneshot(bearer_request( - Method::POST, - "/v1/working-directories/wd-1/sessions", - &token, - serde_json::to_vec(&OpenWorkdirSessionRequest::default()).unwrap(), - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - fn task_request(_objective: &str) -> CreateWorkerRequest { let profile = ProfileSelector::Builtin("builtin:coder".to_string()); let bundle = test_bundle(profile.clone()); @@ -2785,7 +2882,7 @@ mod tests { ) .expect("runtime"), local_token: Some(Arc::from("token")), - auth: None, + workspace_auth: None, workdir_sessions: Arc::new(Mutex::new(HashMap::from([( "session-1".to_string(), RuntimeHttpWorkdirSession { @@ -3161,12 +3258,6 @@ mod tests { .await .unwrap_err(); assert!(matches!(error, RuntimeHttpServerError::AuthRequired)); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let error = serve_runtime_http_with_auth(Runtime::new_memory(), listener, None, None) - .await - .unwrap_err(); - assert!(matches!(error, RuntimeHttpServerError::AuthRequired)); } #[test] diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 8f481903..8bf7c342 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -28,6 +28,7 @@ mod runtime; pub mod worker_backend; pub mod worker_source; pub mod working_directory; +pub mod workspace_issuer; #[cfg(feature = "fs-store")] pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index d064f711..71e867f6 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -12,18 +12,27 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -use worker_runtime::auth::{ - RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key, -}; +use worker_runtime::auth::RuntimeIdentityMaterial; +#[cfg(test)] +use worker_runtime::auth::decode_public_key; use worker_runtime::error::RuntimeError; use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; use worker_runtime::http_server::{ RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection, + WorkspaceRuntimeHttpAuth, }; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::working_directory::RuntimeGitCacheMaterializer; +use worker_runtime::workspace_issuer::{ + FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority, + MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier, + WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord, + add_workspace_issuer_trust, replace_workspace_issuer_trust, revoke_workspace_issuer_trust, + validate_workspace_issuer_trust_records, +}; use worker_runtime::{Runtime, RuntimeOptions}; fn main() -> ExitCode { @@ -72,16 +81,16 @@ fn run() -> Result<(), ProcessError> { } if matches!( args.first().map(String::as_str), - Some("identity" | "trust-server") + Some("identity" | "trust-workspace") ) { return run_auth_command(args); } - let Some(mut config) = parse_args(args)? else { + let Some(config) = parse_args(args)? else { println!("{}", usage()); return Ok(()); }; init_serve_tracing(); - config.http.auth = load_runtime_http_auth(&config)?; + let workspace_http_auth = load_workspace_runtime_http_auth(&config)?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -93,14 +102,28 @@ fn run() -> Result<(), ProcessError> { eprintln!( "yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser" ); - worker_runtime::http_server::serve_runtime_http_with_auth( - worker_runtime, - listener, - config.http.local_token, - config.http.auth, - ) - .await - .map_err(ProcessError::from) + let server = if let Some(workspace_auth) = workspace_http_auth { + worker_runtime::http_server::serve_runtime_http_with_workspace_auth( + worker_runtime, + listener, + config.http.local_token, + workspace_auth, + ) + .await + } else { + let local_token = config.http.local_token.ok_or_else(|| { + ProcessError::auth( + "Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(), + ) + })?; + worker_runtime::http_server::serve_runtime_http( + worker_runtime, + listener, + Some(local_token), + ) + .await + }; + server.map_err(ProcessError::from) })?; Ok(()) } @@ -170,7 +193,7 @@ fn run_migration_command(mut args: Vec) -> Result<(), ProcessError> { println!( "{}", serde_json::to_string_pretty(&plan) - .map_err(|error| ProcessError::Auth(format!("encode migration plan: {error}")))? + .map_err(|error| ProcessError::auth(format!("encode migration plan: {error}")))? ); Ok(()) } @@ -186,25 +209,20 @@ fn build_runtime(config: &ProcessConfig) -> Result { .with_runtime_store_dir(runtime_store_dir); let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?; if let Some(identity) = runtime_auth.identity.clone() { - if let [trusted_server] = runtime_auth.trusted_servers.as_slice() { - factory = - factory.with_runtime_request_identity(identity, trusted_server.server_id.clone()); - } else { - factory = factory.with_remote_worker_mutation_identity(identity); - } + factory = factory.with_remote_worker_mutation_identity(identity); } let mut backend_resource_client: Option< Arc, > = None; if let Some(endpoint) = config.backend_resource_endpoint.clone() { let identity = runtime_auth.identity.as_ref().ok_or_else(|| { - ProcessError::Auth( + ProcessError::auth( "--backend-resource-endpoint requires a configured Runtime identity".to_owned(), ) })?; - let [trusted_server] = runtime_auth.trusted_servers.as_slice() else { - return Err(ProcessError::Auth( - "--backend-resource-endpoint requires exactly one trusted Server identity" + let [workspace_issuer] = runtime_auth.workspace_issuers.as_slice() else { + return Err(ProcessError::auth( + "--backend-resource-endpoint requires exactly one trusted Workspace issuer" .to_owned(), )); }; @@ -213,7 +231,7 @@ fn build_runtime(config: &ProcessConfig) -> Result { endpoint, config.backend_resource_token.clone(), ) - .with_runtime_request_source(identity, trusted_server.server_id.clone()), + .with_runtime_request_source(identity, workspace_issuer.backend_url.clone()), ); factory = factory.with_resource_client(client.clone()); backend_resource_client = Some(client); @@ -233,11 +251,10 @@ fn build_runtime(config: &ProcessConfig) -> Result { } RuntimeHttpStoreSelection::Fs { root } => { let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id( - config - .http - .auth + runtime_auth + .identity .as_ref() - .map(|auth| auth.runtime_id.as_str()) + .map(|identity| identity.identity_id.as_str()) .unwrap_or("local"), ); options.display_name = config.http.display_name.clone(); @@ -468,6 +485,10 @@ impl ProcessError { fn usage(message: String) -> Self { Self::Usage(message) } + + fn auth(message: impl Into) -> Self { + Self::Auth(message.into()) + } } impl fmt::Display for ProcessError { @@ -506,12 +527,25 @@ impl From for ProcessError { } } +const MAX_RUNTIME_AUTH_FILE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES: u64 = 64 * 1024; +const DEFAULT_TRUST_WORKSPACE_LIST_LIMIT: usize = 100; +const MAX_TRUST_WORKSPACE_LIST_LIMIT: usize = 100; + +#[derive(Serialize)] +struct WorkspaceIssuerTrustListPage { + offset: usize, + limit: usize, + total: usize, + records: Vec, +} + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] struct RuntimeAuthFile { #[serde(default, skip_serializing_if = "Option::is_none")] identity: Option, #[serde(default)] - trusted_servers: Vec, + workspace_issuers: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -527,6 +561,266 @@ fn runtime_public_identity_view(identity: &RuntimeIdentityMaterial) -> RuntimePu } } +fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), ProcessError> { + let subcommand = args.pop_front().ok_or_else(|| { + ProcessError::usage( + "trust-workspace requires add, list, show, replace, or revoke".to_string(), + ) + })?; + match subcommand.as_str() { + "add" | "replace" => { + let bundle_path = take_required_auth_option(&mut args, "--bundle")?; + let config = parse_trust_workspace_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let mut auth = read_runtime_auth_file(&auth_path)?; + let bundle_bytes = read_workspace_identity_bundle(Path::new(&bundle_path))?; + let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is invalid") + })?; + let now_unix = unix_now_i64()?; + let (mutation, record) = if subcommand == "add" { + add_workspace_issuer_trust(&mut auth.workspace_issuers, bundle, now_unix) + } else { + replace_workspace_issuer_trust(&mut auth.workspace_issuers, bundle, now_unix) + } + .map_err(workspace_trust_process_error)?; + if mutation != WorkspaceIssuerTrustMutation::Unchanged { + write_runtime_auth_file(&auth_path, &auth)?; + } + print_workspace_trust_mutation(mutation, &record); + Ok(()) + } + "list" => { + let (config, offset, limit) = parse_trust_workspace_list_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let mut records = read_runtime_auth_file(&auth_path)?.workspace_issuers; + records.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id)); + let total = records.len(); + let records = records.into_iter().skip(offset).take(limit).collect(); + let output = serde_json::to_string_pretty(&WorkspaceIssuerTrustListPage { + offset, + limit, + total, + records, + }) + .map_err(|_| ProcessError::auth("Workspace issuer trust output failed"))?; + println!("{output}"); + Ok(()) + } + "show" => { + let workspace_id = take_required_auth_option(&mut args, "--workspace-id")?; + let config = parse_trust_workspace_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let auth = read_runtime_auth_file(&auth_path)?; + let record = auth + .workspace_issuers + .iter() + .find(|record| record.workspace_id == workspace_id) + .ok_or_else(|| ProcessError::auth("Workspace issuer trust is not registered"))?; + let output = serde_json::to_string_pretty(record) + .map_err(|_| ProcessError::auth("Workspace issuer trust output failed"))?; + println!("{output}"); + Ok(()) + } + "revoke" => { + let workspace_id = take_required_auth_option(&mut args, "--workspace-id")?; + let config = parse_trust_workspace_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let mut auth = read_runtime_auth_file(&auth_path)?; + let (mutation, record) = revoke_workspace_issuer_trust( + &mut auth.workspace_issuers, + &workspace_id, + unix_now_i64()?, + ) + .map_err(workspace_trust_process_error)?; + if mutation != WorkspaceIssuerTrustMutation::Unchanged { + write_runtime_auth_file(&auth_path, &auth)?; + } + print_workspace_trust_mutation(mutation, &record); + Ok(()) + } + _ => Err(ProcessError::usage( + "unknown trust-workspace command".to_string(), + )), + } +} + +fn parse_trust_workspace_storage_flags( + args: &mut VecDeque, +) -> Result { + let mut index = 0; + while index < args.len() { + let argument = &args[index]; + if matches!(argument.as_str(), "--fs-root" | "--fs-runtime-dir") { + if index + 1 >= args.len() { + return Err(ProcessError::usage( + "invalid trust-workspace storage arguments".to_string(), + )); + } + index += 2; + continue; + } + if argument + .strip_prefix("--fs-root=") + .or_else(|| argument.strip_prefix("--fs-runtime-dir=")) + .is_some_and(|value| !value.is_empty()) + { + index += 1; + continue; + } + return Err(ProcessError::usage( + "invalid trust-workspace storage arguments".to_string(), + )); + } + parse_auth_storage_flags(args) + .map_err(|_| ProcessError::usage("invalid trust-workspace storage arguments".to_string())) +} + +fn parse_trust_workspace_list_flags( + args: &mut VecDeque, +) -> Result<(ProcessConfig, usize, usize), ProcessError> { + let mut storage = VecDeque::new(); + let mut offset = None; + let mut limit = None; + while let Some(argument) = args.pop_front() { + if matches!(argument.as_str(), "--fs-root" | "--fs-runtime-dir") { + let value = args.pop_front().ok_or_else(|| { + ProcessError::usage("invalid trust-workspace list arguments".to_string()) + })?; + storage.push_back(argument); + storage.push_back(value); + continue; + } + if argument.starts_with("--fs-root=") || argument.starts_with("--fs-runtime-dir=") { + if argument.ends_with('=') { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + storage.push_back(argument); + continue; + } + let (name, inline_value) = argument + .split_once('=') + .map_or((argument.as_str(), None), |(name, value)| { + (name, Some(value)) + }); + if !matches!(name, "--offset" | "--limit") { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + let value = match inline_value { + Some(value) if !value.is_empty() => value.to_string(), + Some(_) => { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + None => args.pop_front().ok_or_else(|| { + ProcessError::usage("invalid trust-workspace list arguments".to_string()) + })?, + }; + let parsed = value.parse::().map_err(|_| { + ProcessError::usage("invalid trust-workspace list arguments".to_string()) + })?; + match name { + "--offset" if offset.replace(parsed).is_none() => {} + "--limit" + if (1..=MAX_TRUST_WORKSPACE_LIST_LIMIT).contains(&parsed) + && limit.replace(parsed).is_none() => {} + _ => { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + } + } + let config = parse_trust_workspace_storage_flags(&mut storage)?; + let offset = offset.unwrap_or(0); + if offset > MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + Ok(( + config, + offset, + limit.unwrap_or(DEFAULT_TRUST_WORKSPACE_LIST_LIMIT), + )) +} + +fn workspace_trust_process_error(_: WorkspaceIssuerTrustError) -> ProcessError { + ProcessError::auth("Workspace issuer trust mutation was rejected") +} + +fn read_workspace_identity_bundle(path: &Path) -> Result, ProcessError> { + use std::io::Read as _; + + let file = std::fs::File::open(path).map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is unavailable") + })?; + let mut bytes = Vec::new(); + file.take(MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is unavailable") + })?; + if bytes.len() as u64 > MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES { + return Err(ProcessError::auth( + "Workspace signing public identity bundle is too large", + )); + } + Ok(bytes) +} + +fn take_required_auth_option( + args: &mut VecDeque, + expected: &str, +) -> Result { + let argument = args + .pop_front() + .ok_or_else(|| ProcessError::usage(format!("missing required `{expected}`")))?; + let (flag, inline_value) = split_flag_value(argument) + .map_err(|_| ProcessError::usage("invalid trust-workspace argument".to_string()))?; + if flag != expected { + return Err(ProcessError::usage( + "invalid trust-workspace argument".to_string(), + )); + } + take_value(&flag, inline_value, args) + .map_err(|_| ProcessError::usage("invalid trust-workspace argument".to_string())) +} + +fn unix_now_i64() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ProcessError::auth("system clock is before the Unix epoch"))? + .as_secs(); + i64::try_from(now).map_err(|_| ProcessError::auth("system clock is out of range")) +} + +fn print_workspace_trust_mutation( + mutation: WorkspaceIssuerTrustMutation, + record: &WorkspaceIssuerTrustRecord, +) { + let action = match mutation { + WorkspaceIssuerTrustMutation::Added => "added", + WorkspaceIssuerTrustMutation::Replaced => "replaced", + WorkspaceIssuerTrustMutation::Revoked => "revoked", + WorkspaceIssuerTrustMutation::Unchanged => "unchanged", + }; + println!( + "Workspace issuer trust {action}: workspace={} key={} fingerprint={} identity_revision={} trust_generation={} state={:?}", + record.workspace_id, + record.key_id, + record.public_key_fingerprint, + record.identity_revision, + record.trust_generation, + record.state, + ); +} + fn runtime_auth_path(config: &ProcessConfig) -> PathBuf { config.resolved_fs_paths().runtime_dir.join("auth.toml") } @@ -535,50 +829,92 @@ fn read_runtime_auth_file(path: &Path) -> Result if !path.exists() { return Ok(RuntimeAuthFile::default()); } - let contents = std::fs::read_to_string(path)?; - toml::from_str(&contents) - .map_err(|error| ProcessError::Auth(format!("failed to parse {}: {error}", path.display()))) + use std::io::Read as _; + + let file = std::fs::File::open(path) + .map_err(|_| ProcessError::auth("runtime auth store is unavailable"))?; + let mut contents = Vec::new(); + file.take(MAX_RUNTIME_AUTH_FILE_BYTES + 1) + .read_to_end(&mut contents) + .map_err(|_| ProcessError::auth("runtime auth store is unavailable"))?; + if contents.len() as u64 > MAX_RUNTIME_AUTH_FILE_BYTES { + return Err(ProcessError::auth("runtime auth store is too large")); + } + let contents = String::from_utf8(contents) + .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; + let auth: RuntimeAuthFile = toml::from_str(&contents) + .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; + validate_workspace_issuer_trust_records(&auth.workspace_issuers) + .map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?; + Ok(auth) } fn write_runtime_auth_file(path: &Path, auth: &RuntimeAuthFile) -> Result<(), ProcessError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let contents = toml::to_string_pretty(auth).map_err(|error| { - ProcessError::Auth(format!("failed to serialize {}: {error}", path.display())) - })?; + let contents = toml::to_string_pretty(auth) + .map_err(|_| ProcessError::auth("runtime auth store serialization failed"))?; + if contents.len() as u64 > MAX_RUNTIME_AUTH_FILE_BYTES { + return Err(ProcessError::auth("runtime auth store is too large")); + } write_secret_file(path, contents.as_bytes()) } fn write_secret_file(path: &Path, contents: &[u8]) -> Result<(), ProcessError> { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; + use std::io::Write as _; + + let parent = path + .parent() + .ok_or_else(|| ProcessError::auth("runtime auth store path has no parent"))?; + let temporary_path = parent.join(format!(".runtime-auth-{}.tmp", uuid::Uuid::now_v7())); + let write_result = (|| -> Result<(), ProcessError> { let mut options = std::fs::OpenOptions::new(); - options.create(true).write(true).truncate(true).mode(0o600); - std::io::Write::write_all(&mut options.open(path)?, contents)?; + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&temporary_path)?; + file.write_all(contents)?; + file.sync_all()?; + std::fs::rename(&temporary_path, path)?; + std::fs::File::open(parent)?.sync_all()?; + Ok(()) + })(); + if write_result.is_err() { + let _ = std::fs::remove_file(&temporary_path); } - #[cfg(not(unix))] - { - std::fs::write(path, contents)?; - } - Ok(()) + write_result } -fn load_runtime_http_auth( +fn load_workspace_runtime_http_auth( config: &ProcessConfig, -) -> Result, ProcessError> { - let path = runtime_auth_path(config); - let auth = read_runtime_auth_file(&path)?; +) -> Result, ProcessError> { + let auth = read_runtime_auth_file(&runtime_auth_path(config))?; let Some(identity) = auth.identity else { return Ok(None); }; - if auth.trusted_servers.is_empty() { + if auth.workspace_issuers.is_empty() { return Ok(None); } - Ok(Some(RuntimeHttpAuthConfig { - runtime_id: identity.identity_id, - trusted_servers: auth.trusted_servers, + let replay_path = runtime_auth_path(config).with_extension("workspace-replay.json"); + let verifier = WorkspaceCapabilityVerifier::new( + auth.workspace_issuers, + Arc::new(FileWorkspaceClaimReplayProtection::new(replay_path)), + ) + .map_err(|error| ProcessError::auth(format!("invalid Workspace issuer trust: {error}")))?; + let signer = RuntimeVerificationSigner::from_identity(&identity) + .map_err(|error| ProcessError::auth(format!("invalid Runtime identity: {error}")))?; + let verifications_path = + runtime_auth_path(config).with_extension("workspace-verifications.json"); + Ok(Some(WorkspaceRuntimeHttpAuth { + verifier, + signer, + verifications: Arc::new(FileWorkspaceRuntimeVerificationAuthority::new( + verifications_path, + )), })) } @@ -608,7 +944,7 @@ fn run_auth_command(args: Vec) -> Result<(), ProcessError> { let command = args.pop_front().unwrap_or_default(); match command.as_str() { "identity" => run_identity_command(args), - "trust-server" => run_trust_server_command(args), + "trust-workspace" => run_trust_workspace_command(args), _ => Err(ProcessError::usage(format!( "unknown auth command `{command}`" ))), @@ -668,7 +1004,7 @@ fn run_identity_command(mut args: VecDeque) -> Result<(), ProcessError> })?; auth.identity = Some( RuntimeIdentityMaterial::generate(runtime_id) - .map_err(|error| ProcessError::Auth(error.to_string()))?, + .map_err(|error| ProcessError::auth(error.to_string()))?, ); write_runtime_auth_file(&path, &auth)?; let identity = auth.identity.as_ref().unwrap(); @@ -721,7 +1057,7 @@ fn run_identity_command(mut args: VecDeque) -> Result<(), ProcessError> println!( "{}", serde_json::to_string_pretty(&view) - .map_err(|error| ProcessError::Auth(error.to_string()))? + .map_err(|error| ProcessError::auth(error.to_string()))? ); } else { println!("runtime_id={}", view.identity_id); @@ -736,187 +1072,6 @@ fn run_identity_command(mut args: VecDeque) -> Result<(), ProcessError> } } -fn run_trust_server_command(mut args: VecDeque) -> Result<(), ProcessError> { - let subcommand = args.pop_front().ok_or_else(|| { - ProcessError::usage( - "trust-server requires subcommand `add`, `list`, or `revoke`".to_string(), - ) - })?; - match subcommand.as_str() { - "add" => { - let mut server_id = None; - let mut public_key = None; - let mut display_name = None; - let mut replace = false; - let mut rest = VecDeque::new(); - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?), - "--public-key" => { - public_key = Some(take_value(&flag, inline_value, &mut args)?) - } - "--display-name" => { - display_name = Some(take_value(&flag, inline_value, &mut args)?) - } - "--replace" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - replace = true; - } - "--fs-root" | "--fs-runtime-dir" => { - rest.push_back(flag); - if let Some(value) = inline_value { - rest.push_back(value); - } else { - rest.push_back(args.pop_front().ok_or_else(|| { - ProcessError::usage(format!( - "{} requires a value", - rest.back().unwrap() - )) - })?); - } - } - _ => { - return Err(ProcessError::usage(format!( - "unknown trust-server add argument `{flag}`" - ))); - } - } - } - let config = parse_auth_storage_flags(&mut rest)?; - let path = runtime_auth_path(&config); - let mut auth = read_runtime_auth_file(&path)?; - let server_id = server_id.ok_or_else(|| { - ProcessError::usage("trust-server add requires --server-id".to_string()) - })?; - let public_key = public_key.ok_or_else(|| { - ProcessError::usage("trust-server add requires --public-key".to_string()) - })?; - decode_public_key(&public_key) - .map_err(|error| ProcessError::usage(error.to_string()))?; - if auth - .trusted_servers - .iter() - .any(|server| server.server_id == server_id) - && !replace - { - return Err(ProcessError::usage(format!( - "trusted server `{server_id}` already exists; pass --replace to update it" - ))); - } - auth.trusted_servers - .retain(|server| server.server_id != server_id); - auth.trusted_servers.push(TrustedServerKey { - server_id: server_id.clone(), - public_key, - display_name, - }); - write_runtime_auth_file(&path, &auth)?; - println!("trusted_server_id={server_id}"); - println!("auth_file={}", path.display()); - Ok(()) - } - "list" => { - let mut json = false; - let mut rest = VecDeque::new(); - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--json" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - json = true; - } - "--fs-root" | "--fs-runtime-dir" => { - rest.push_back(flag); - if let Some(value) = inline_value { - rest.push_back(value); - } else { - rest.push_back(args.pop_front().ok_or_else(|| { - ProcessError::usage(format!( - "{} requires a value", - rest.back().unwrap() - )) - })?); - } - } - _ => { - return Err(ProcessError::usage(format!( - "unknown trust-server list argument `{flag}`" - ))); - } - } - } - let config = parse_auth_storage_flags(&mut rest)?; - let auth = read_runtime_auth_file(&runtime_auth_path(&config))?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&auth.trusted_servers) - .map_err(|error| ProcessError::Auth(error.to_string()))? - ); - } else { - for server in auth.trusted_servers { - println!( - "server_id={} public_key={} display_name={}", - server.server_id, - server.public_key, - server.display_name.unwrap_or_default() - ); - } - } - Ok(()) - } - "revoke" => { - let mut server_id = None; - let mut rest = VecDeque::new(); - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?), - "--fs-root" | "--fs-runtime-dir" => { - rest.push_back(flag); - if let Some(value) = inline_value { - rest.push_back(value); - } else { - rest.push_back(args.pop_front().ok_or_else(|| { - ProcessError::usage(format!( - "{} requires a value", - rest.back().unwrap() - )) - })?); - } - } - _ => { - return Err(ProcessError::usage(format!( - "unknown trust-server revoke argument `{flag}`" - ))); - } - } - } - let config = parse_auth_storage_flags(&mut rest)?; - let path = runtime_auth_path(&config); - let mut auth = read_runtime_auth_file(&path)?; - let server_id = server_id.ok_or_else(|| { - ProcessError::usage("trust-server revoke requires --server-id".to_string()) - })?; - let before = auth.trusted_servers.len(); - auth.trusted_servers - .retain(|server| server.server_id != server_id); - if auth.trusted_servers.len() == before { - return Err(ProcessError::usage(format!( - "trusted server `{server_id}` is not registered" - ))); - } - write_runtime_auth_file(&path, &auth)?; - println!("revoked_server_id={server_id}"); - Ok(()) - } - _ => Err(ProcessError::usage(format!( - "unknown trust-server subcommand `{subcommand}`" - ))), - } -} - fn usage() -> &'static str { r#"Usage: yoi-runtime [OPTIONS] yoi-runtime migrate --dry-run [--runtime-id ] [OPTIONS] @@ -942,9 +1097,11 @@ Options: Auth commands: identity init --runtime-id ID [--replace] [--fs-root PATH] [--fs-runtime-dir PATH] identity show [--json] [--fs-root PATH] [--fs-runtime-dir PATH] - trust-server add --server-id ID --public-key KEY [--display-name NAME] [--replace] [--fs-root PATH] [--fs-runtime-dir PATH] - trust-server list [--json] [--fs-root PATH] [--fs-runtime-dir PATH] - trust-server revoke --server-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"# + trust-workspace add --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace list [--offset N] [--limit N] [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace show --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace replace --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"# } #[cfg(test)] @@ -1121,6 +1278,249 @@ mod tests { assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before); } + #[test] + fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() { + use sha2::{Digest as _, Sha256}; + use workspace_api::WorkspacePublicIdentityBundle; + + let temp = tempfile::tempdir().unwrap(); + let identity = RuntimeIdentityMaterial::generate("WK-1").unwrap(); + let public_key = decode_public_key(&identity.public_key).unwrap(); + let fingerprint = Sha256::digest(public_key); + let fingerprint = format!( + "sha256:{}", + fingerprint + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let bundle_path = temp.path().join("workspace-public.json"); + std::fs::write( + &bundle_path, + serde_json::to_vec(&WorkspacePublicIdentityBundle { + workspace_id: "workspace-1".to_string(), + backend_url: "https://backend.example.test".to_string(), + key_id: "WK-1".to_string(), + algorithm: "ed25519".to_string(), + public_key: identity.public_key, + public_key_fingerprint: fingerprint, + revision: 1, + }) + .unwrap(), + ) + .unwrap(); + + run_trust_workspace_command(VecDeque::from([ + "add".to_string(), + "--bundle".to_string(), + bundle_path.to_string_lossy().into_owned(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ])) + .unwrap(); + let config = ProcessConfig { + fs_root: Some(temp.path().to_path_buf()), + ..ProcessConfig::default().unwrap() + }; + let path = runtime_auth_path(&config); + let first = read_runtime_auth_file(&path).unwrap(); + assert_eq!(first.workspace_issuers.len(), 1); + assert_eq!(first.workspace_issuers[0].trust_generation, 1); + + run_trust_workspace_command(VecDeque::from([ + "add".to_string(), + "--bundle".to_string(), + bundle_path.to_string_lossy().into_owned(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ])) + .unwrap(); + let replay = read_runtime_auth_file(&path).unwrap(); + assert_eq!(replay.workspace_issuers[0].trust_generation, 1); + + let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap(); + let public_key = decode_public_key(&replacement.public_key).unwrap(); + let fingerprint = Sha256::digest(public_key); + let fingerprint = format!( + "sha256:{}", + fingerprint + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + std::fs::write( + &bundle_path, + serde_json::to_vec(&WorkspacePublicIdentityBundle { + workspace_id: "workspace-1".to_string(), + backend_url: "https://backend.example.test".to_string(), + key_id: "WK-2".to_string(), + algorithm: "ed25519".to_string(), + public_key: replacement.public_key, + public_key_fingerprint: fingerprint, + revision: 2, + }) + .unwrap(), + ) + .unwrap(); + for command in ["replace", "show", "list"] { + let mut args = VecDeque::from([command.to_string()]); + match command { + "replace" => { + args.push_back("--bundle".to_string()); + args.push_back(bundle_path.to_string_lossy().into_owned()); + } + "show" => { + args.push_back("--workspace-id".to_string()); + args.push_back("workspace-1".to_string()); + } + "list" => {} + _ => unreachable!(), + } + args.push_back("--fs-root".to_string()); + args.push_back(temp.path().to_string_lossy().into_owned()); + run_trust_workspace_command(args).unwrap(); + } + run_trust_workspace_command(VecDeque::from([ + "revoke".to_string(), + "--workspace-id".to_string(), + "workspace-1".to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ])) + .unwrap(); + let revoked = read_runtime_auth_file(&path).unwrap(); + assert_eq!(revoked.workspace_issuers[0].trust_generation, 3); + assert_eq!( + revoked.workspace_issuers[0].state, + worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked + ); + + let oversized_bundle = temp.path().join("oversized-public-bundle.json"); + std::fs::write( + &oversized_bundle, + vec![b'x'; MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES as usize + 1], + ) + .unwrap(); + let error = read_workspace_identity_bundle(&oversized_bundle) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "Workspace signing public identity bundle is too large" + ); + assert!(!error.contains(&oversized_bundle.to_string_lossy().into_owned())); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn corrupt_workspace_issuer_trust_fails_closed_without_echoing_store_content() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime-auth.toml"); + let private_marker = "private-key-material-must-not-appear"; + std::fs::write( + &path, + format!("identity = {{ private_key_pkcs8 = \"{private_marker}\" }}\n[[workspace_issuers]]\nworkspace_id = []\n"), + ) + .unwrap(); + let error = read_runtime_auth_file(&path).unwrap_err().to_string(); + assert_eq!(error, "runtime auth store is corrupt"); + assert!(!error.contains(private_marker)); + assert!(!error.contains(&path.to_string_lossy().into_owned())); + } + + #[test] + fn trust_workspace_errors_and_pagination_are_bounded_and_secret_free() { + let temp = tempfile::tempdir().unwrap(); + let secret_path = "/private/operator/path/must-not-appear"; + for args in [ + VecDeque::from([secret_path.to_string()]), + VecDeque::from([ + "show".to_string(), + "--workspace-id".to_string(), + secret_path.to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ]), + VecDeque::from([ + "list".to_string(), + secret_path.to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ]), + ] { + let error = run_trust_workspace_command(args).unwrap_err().to_string(); + assert!( + !error.contains(secret_path), + "unexpected diagnostic: {error}" + ); + } + + let mut page_args = VecDeque::from([ + "--offset".to_string(), + "2".to_string(), + "--limit=7".to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ]); + let (_, offset, limit) = parse_trust_workspace_list_flags(&mut page_args).unwrap(); + assert_eq!((offset, limit), (2, 7)); + let error = parse_trust_workspace_list_flags(&mut VecDeque::from([ + "--limit".to_string(), + "101".to_string(), + ])) + .unwrap_err() + .to_string(); + assert_eq!(error, "invalid trust-workspace list arguments"); + } + + #[test] + fn oversized_runtime_auth_store_fails_closed_before_parsing() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime-auth.toml"); + std::fs::write(&path, vec![b'x'; MAX_RUNTIME_AUTH_FILE_BYTES as usize + 1]).unwrap(); + assert_eq!( + read_runtime_auth_file(&path).unwrap_err().to_string(), + "runtime auth store is too large" + ); + } + + #[test] + fn legacy_server_trust_entries_are_dropped_when_auth_store_is_rewritten() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime-auth.toml"); + let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + std::fs::write( + &path, + format!( + "identity = {{ identity_id = \"{}\", private_key = \"{}\", public_key = \"{}\" }}\n[[trusted_servers]]\nserver_id = \"removed\"\npublic_key = \"removed\"\n", + identity.identity_id, identity.private_key, identity.public_key + ), + ) + .unwrap(); + let auth = read_runtime_auth_file(&path).unwrap(); + write_runtime_auth_file(&path, &auth).unwrap(); + let rewritten = std::fs::read_to_string(path).unwrap(); + assert!(!rewritten.contains("trusted_servers")); + assert!(!rewritten.contains("server_id")); + assert!(rewritten.contains("identity_id = \"runtime-a\"")); + } + + #[test] + fn removed_server_trust_command_is_rejected() { + let error = run_auth_command(vec!["trust-server".to_owned(), "list".to_owned()]) + .unwrap_err() + .to_string(); + assert_eq!(error, "unknown auth command `trust-server`"); + } + #[test] fn no_store_disables_runtime_catalog_persistence() { let config = parse_args(["--no-store"]).unwrap().unwrap(); diff --git a/crates/worker-runtime/src/workspace_issuer.rs b/crates/worker-runtime/src/workspace_issuer.rs new file mode 100644 index 00000000..dd4a05c1 --- /dev/null +++ b/crates/worker-runtime/src/workspace_issuer.rs @@ -0,0 +1,1796 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ring::signature::Ed25519KeyPair; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use workspace_api::WorkspacePublicIdentityBundle; + +use crate::auth::RuntimeAuthError; + +const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v1"; +const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1."; +const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; +const MAX_TOKEN_BYTES: usize = 16 * 1024; +const MAX_ID_BYTES: usize = 256; +const MAX_ISSUER_BYTES: usize = 2 * 1024; +const MAX_OPERATION_BYTES: usize = 128; +const MAX_PATH_AND_QUERY_BYTES: usize = 4 * 1024; +pub const MAX_WORKSPACE_ISSUER_TRUST_RECORDS: usize = 4_096; +const MAX_REPLAY_ENTRIES: usize = 65_536; +const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300; +const MAX_CLOCK_SKEW_SECONDS: i64 = 30; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceIssuerTrustState { + Active, + Revoked, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceIssuerTrustRecord { + pub workspace_id: String, + pub backend_url: String, + pub key_id: String, + pub algorithm: String, + pub public_key: String, + pub public_key_fingerprint: String, + pub identity_revision: u64, + pub trust_generation: u64, + pub state: WorkspaceIssuerTrustState, + pub registered_at_unix: i64, + pub updated_at_unix: i64, +} + +impl WorkspaceIssuerTrustRecord { + pub fn from_bundle( + bundle: WorkspacePublicIdentityBundle, + trust_generation: u64, + now_unix: i64, + ) -> Result { + validate_bundle(&bundle)?; + if trust_generation == 0 { + return Err(WorkspaceIssuerTrustError::InvalidTrustGeneration); + } + Ok(Self { + workspace_id: bundle.workspace_id, + backend_url: bundle.backend_url, + key_id: bundle.key_id, + algorithm: bundle.algorithm, + public_key: bundle.public_key, + public_key_fingerprint: bundle.public_key_fingerprint, + identity_revision: bundle.revision, + trust_generation, + state: WorkspaceIssuerTrustState::Active, + registered_at_unix: now_unix, + updated_at_unix: now_unix, + }) + } + + pub fn public_bundle(&self) -> WorkspacePublicIdentityBundle { + WorkspacePublicIdentityBundle { + workspace_id: self.workspace_id.clone(), + backend_url: self.backend_url.clone(), + key_id: self.key_id.clone(), + algorithm: self.algorithm.clone(), + public_key: self.public_key.clone(), + public_key_fingerprint: self.public_key_fingerprint.clone(), + revision: self.identity_revision, + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WorkspaceIssuerTrustError { + #[error("Workspace issuer trust already exists for `{0}`")] + AlreadyExists(String), + #[error("Workspace issuer trust is not registered for `{0}`")] + NotFound(String), + #[error("Workspace signing algorithm is not supported")] + UnsupportedAlgorithm, + #[error("Workspace signing public identity backend URL is invalid")] + InvalidBackendUrl, + #[error("Workspace signing public identity contains an invalid identifier")] + InvalidIdentifier, + #[error("Workspace signing public key is invalid")] + InvalidPublicKey, + #[error("Workspace signing public key fingerprint does not match the public key")] + PublicKeyFingerprintMismatch, + #[error("Workspace signing identity revision must be greater than zero")] + InvalidIdentityRevision, + #[error("Workspace issuer trust generation must be greater than zero")] + InvalidTrustGeneration, + #[error("Workspace signing identity replacement revision is stale")] + StaleIdentityRevision, + #[error("Workspace issuer trust record limit was reached")] + TrustRecordLimitExceeded, + #[error("Workspace issuer trust generation overflow")] + TrustGenerationOverflow, +} + +pub fn validate_workspace_issuer_trust_records( + records: &[WorkspaceIssuerTrustRecord], +) -> Result<(), WorkspaceCapabilityVerificationError> { + if records.len() > MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded); + } + let mut seen = std::collections::HashSet::new(); + for record in records { + validate_trust_record(record)?; + if !seen.insert(record.workspace_id.clone()) { + return Err(WorkspaceCapabilityVerificationError::DuplicateWorkspaceTrust); + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorkspaceIssuerTrustMutation { + Added, + Replaced, + Revoked, + Unchanged, +} + +pub fn add_workspace_issuer_trust( + records: &mut Vec, + bundle: WorkspacePublicIdentityBundle, + now_unix: i64, +) -> Result<(WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord), WorkspaceIssuerTrustError> { + validate_bundle(&bundle)?; + if let Some(existing) = records + .iter() + .find(|record| record.workspace_id == bundle.workspace_id) + { + if existing.state == WorkspaceIssuerTrustState::Active && existing.public_bundle() == bundle + { + return Ok((WorkspaceIssuerTrustMutation::Unchanged, existing.clone())); + } + return Err(WorkspaceIssuerTrustError::AlreadyExists( + bundle.workspace_id, + )); + } + if records.len() >= MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceIssuerTrustError::TrustRecordLimitExceeded); + } + let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, now_unix)?; + records.push(record.clone()); + records.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id)); + Ok((WorkspaceIssuerTrustMutation::Added, record)) +} + +pub fn replace_workspace_issuer_trust( + records: &mut [WorkspaceIssuerTrustRecord], + bundle: WorkspacePublicIdentityBundle, + now_unix: i64, +) -> Result<(WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord), WorkspaceIssuerTrustError> { + validate_bundle(&bundle)?; + let record = records + .iter_mut() + .find(|record| record.workspace_id == bundle.workspace_id) + .ok_or_else(|| WorkspaceIssuerTrustError::NotFound(bundle.workspace_id.clone()))?; + if record.state == WorkspaceIssuerTrustState::Active && record.public_bundle() == bundle { + return Ok((WorkspaceIssuerTrustMutation::Unchanged, record.clone())); + } + if bundle.revision < record.identity_revision { + return Err(WorkspaceIssuerTrustError::StaleIdentityRevision); + } + if bundle.revision == record.identity_revision + && (bundle.key_id != record.key_id + || bundle.public_key != record.public_key + || bundle.public_key_fingerprint != record.public_key_fingerprint) + { + return Err(WorkspaceIssuerTrustError::StaleIdentityRevision); + } + let generation = record + .trust_generation + .checked_add(1) + .ok_or(WorkspaceIssuerTrustError::TrustGenerationOverflow)?; + let registered_at_unix = record.registered_at_unix; + *record = WorkspaceIssuerTrustRecord::from_bundle(bundle, generation, now_unix)?; + record.registered_at_unix = registered_at_unix; + Ok((WorkspaceIssuerTrustMutation::Replaced, record.clone())) +} + +pub fn revoke_workspace_issuer_trust( + records: &mut [WorkspaceIssuerTrustRecord], + workspace_id: &str, + now_unix: i64, +) -> Result<(WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord), WorkspaceIssuerTrustError> { + validate_id(workspace_id)?; + let record = records + .iter_mut() + .find(|record| record.workspace_id == workspace_id) + .ok_or_else(|| WorkspaceIssuerTrustError::NotFound(workspace_id.to_string()))?; + if record.state == WorkspaceIssuerTrustState::Revoked { + return Ok((WorkspaceIssuerTrustMutation::Unchanged, record.clone())); + } + record.trust_generation = record + .trust_generation + .checked_add(1) + .ok_or(WorkspaceIssuerTrustError::TrustGenerationOverflow)?; + record.state = WorkspaceIssuerTrustState::Revoked; + record.updated_at_unix = now_unix; + Ok((WorkspaceIssuerTrustMutation::Revoked, record.clone())) +} + +fn validate_bundle( + bundle: &WorkspacePublicIdentityBundle, +) -> Result<(), WorkspaceIssuerTrustError> { + validate_id(&bundle.workspace_id)?; + validate_id(&bundle.key_id)?; + if bundle.backend_url.len() > MAX_ISSUER_BYTES + || bundle.backend_url.trim() != bundle.backend_url + || bundle.backend_url.chars().any(char::is_control) + { + return Err(WorkspaceIssuerTrustError::InvalidBackendUrl); + } + let backend_url = url::Url::parse(&bundle.backend_url) + .map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?; + if !matches!(backend_url.scheme(), "http" | "https") + || backend_url.host_str().is_none() + || !backend_url.username().is_empty() + || backend_url.password().is_some() + || backend_url.query().is_some() + || backend_url.fragment().is_some() + { + return Err(WorkspaceIssuerTrustError::InvalidBackendUrl); + } + if bundle.algorithm != WORKSPACE_SIGNING_ALGORITHM { + return Err(WorkspaceIssuerTrustError::UnsupportedAlgorithm); + } + if bundle.revision == 0 { + return Err(WorkspaceIssuerTrustError::InvalidIdentityRevision); + } + let public_key = crate::auth::decode_public_key(&bundle.public_key) + .map_err(|_| WorkspaceIssuerTrustError::InvalidPublicKey)?; + let fingerprint = format!("sha256:{}", hex_lower(&Sha256::digest(&public_key))); + if fingerprint != bundle.public_key_fingerprint { + return Err(WorkspaceIssuerTrustError::PublicKeyFingerprintMismatch); + } + Ok(()) +} + +fn validate_id(value: &str) -> Result<(), WorkspaceIssuerTrustError> { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.trim() != value + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(WorkspaceIssuerTrustError::InvalidIdentifier); + } + Ok(()) +} + +pub const WORKSPACE_VERIFICATION_CHALLENGE_PATH: &str = + "/v1/workspace-runtime-verification/challenge"; +pub const WORKSPACE_VERIFICATION_ACK_PATH: &str = + "/v1/workspace-runtime-verification/acknowledgement"; +pub const WORKSPACE_VERIFICATION_OPERATION: &str = "workspace.runtime.verify"; +const RUNTIME_VERIFICATION_TOKEN_PREFIX: &str = "yoi-runtime-verification-v1"; +const RUNTIME_VERIFICATION_SIGNING_INPUT_PREFIX: &str = "yoi.runtime.verification.v1."; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeVerificationChallenge { + pub challenge_id: String, + pub workspace_id: String, + pub runtime_id: String, + pub binding_revision: u64, + pub workspace_key_id: String, + pub workspace_identity_revision: u64, + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + pub runtime_identity_revision: u64, + pub workspace_nonce: String, + pub expires_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeVerificationResponse { + pub challenge_id: String, + pub workspace_id: String, + pub runtime_id: String, + pub binding_revision: u64, + pub workspace_key_id: String, + pub workspace_identity_revision: u64, + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + pub runtime_identity_revision: u64, + pub workspace_nonce: String, + pub runtime_nonce: String, + pub expires_at: i64, + pub response_proof: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeVerificationAcknowledgement { + pub challenge_id: String, + pub workspace_id: String, + pub runtime_id: String, + pub binding_revision: u64, + pub workspace_key_id: String, + pub workspace_identity_revision: u64, + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + pub runtime_identity_revision: u64, + pub workspace_nonce: String, + pub runtime_nonce: String, + pub response_digest: String, + pub response: WorkspaceRuntimeVerificationResponse, + pub expires_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeVerificationReceipt { + pub challenge_id: String, + pub workspace_id: String, + pub runtime_id: String, + pub binding_revision: u64, + pub accepted_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeVerificationRecord { + pub workspace_id: String, + pub runtime_id: String, + pub binding_revision: u64, + pub workspace_key_id: String, + pub workspace_identity_revision: u64, + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + pub runtime_identity_revision: u64, + pub verified_at: i64, +} + +pub trait WorkspaceRuntimeVerificationAuthority: fmt::Debug + Send + Sync { + fn get( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result, WorkspaceCapabilityVerificationError>; + fn record( + &self, + record: WorkspaceRuntimeVerificationRecord, + ) -> Result<(), WorkspaceCapabilityVerificationError>; +} + +#[derive(Debug, Default)] +pub struct InMemoryWorkspaceRuntimeVerificationAuthority { + records: Mutex>, +} + +impl WorkspaceRuntimeVerificationAuthority for InMemoryWorkspaceRuntimeVerificationAuthority { + fn get( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result, WorkspaceCapabilityVerificationError> + { + Ok(self + .records + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)? + .get(&(workspace_id.to_string(), runtime_id.to_string())) + .cloned()) + } + + fn record( + &self, + record: WorkspaceRuntimeVerificationRecord, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_verification_record(&record)?; + self.records + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)? + .insert( + (record.workspace_id.clone(), record.runtime_id.clone()), + record, + ); + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct FileWorkspaceRuntimeVerificationAuthority { + path: PathBuf, + lock: Arc>, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceRuntimeVerificationDocument { + version: u32, + records: Vec, +} + +impl FileWorkspaceRuntimeVerificationAuthority { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Arc::new(Mutex::new(())), + } + } + + fn read( + &self, + ) -> Result { + match fs::read(&self.path) { + Ok(bytes) => { + let document: WorkspaceRuntimeVerificationDocument = serde_json::from_slice(&bytes) + .map_err(|_| { + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable + })?; + if document.version != 1 + || document.records.len() > MAX_WORKSPACE_ISSUER_TRUST_RECORDS + { + return Err( + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable, + ); + } + let mut identities = HashSet::new(); + for record in &document.records { + validate_verification_record(record)?; + if !identities + .insert((record.workspace_id.as_str(), record.runtime_id.as_str())) + { + return Err( + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable, + ); + } + } + Ok(document) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(WorkspaceRuntimeVerificationDocument { + version: 1, + records: Vec::new(), + }) + } + Err(_) => Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable), + } + } + + fn write( + &self, + document: &WorkspaceRuntimeVerificationDocument, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + let parent = self.path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + let temporary = parent.join(format!( + ".workspace-runtime-verification-{}.tmp", + uuid::Uuid::now_v7() + )); + let bytes = serde_json::to_vec(document) + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + fs::write(&temporary, bytes) + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(|_| { + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable + })?; + } + fs::rename(&temporary, &self.path).map_err(|_| { + let _ = fs::remove_file(&temporary); + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable + })?; + Ok(()) + } +} + +impl WorkspaceRuntimeVerificationAuthority for FileWorkspaceRuntimeVerificationAuthority { + fn get( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result, WorkspaceCapabilityVerificationError> + { + let _guard = self + .lock + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + Ok(self + .read()? + .records + .into_iter() + .find(|record| record.workspace_id == workspace_id && record.runtime_id == runtime_id)) + } + + fn record( + &self, + record: WorkspaceRuntimeVerificationRecord, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_verification_record(&record)?; + let _guard = self + .lock + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + let mut document = self.read()?; + document.records.retain(|current| { + current.workspace_id != record.workspace_id || current.runtime_id != record.runtime_id + }); + document.records.push(record); + self.write(&document) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeVerificationResponseClaims { + response: WorkspaceRuntimeVerificationResponseUnsigned, + method: String, + path_and_query: String, + body_digest: String, + iat: i64, + exp: i64, + jti: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceRuntimeVerificationResponseUnsigned { + challenge_id: String, + workspace_id: String, + runtime_id: String, + binding_revision: u64, + workspace_key_id: String, + workspace_identity_revision: u64, + workspace_trust_generation: u64, + runtime_public_key_fingerprint: String, + runtime_identity_revision: u64, + workspace_nonce: String, + runtime_nonce: String, + expires_at: i64, +} + +impl WorkspaceRuntimeVerificationResponse { + fn unsigned(&self) -> WorkspaceRuntimeVerificationResponseUnsigned { + WorkspaceRuntimeVerificationResponseUnsigned { + challenge_id: self.challenge_id.clone(), + workspace_id: self.workspace_id.clone(), + runtime_id: self.runtime_id.clone(), + binding_revision: self.binding_revision, + workspace_key_id: self.workspace_key_id.clone(), + workspace_identity_revision: self.workspace_identity_revision, + workspace_trust_generation: self.workspace_trust_generation, + runtime_public_key_fingerprint: self.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: self.runtime_identity_revision, + workspace_nonce: self.workspace_nonce.clone(), + runtime_nonce: self.runtime_nonce.clone(), + expires_at: self.expires_at, + } + } +} + +#[derive(Clone)] +pub struct RuntimeVerificationSigner { + runtime_id: String, + public_key: String, + public_key_fingerprint: String, + signing_key: Arc, +} + +impl fmt::Debug for RuntimeVerificationSigner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeVerificationSigner") + .field("runtime_id", &self.runtime_id) + .field("public_key_fingerprint", &self.public_key_fingerprint) + .finish_non_exhaustive() + } +} + +impl RuntimeVerificationSigner { + pub fn from_identity( + identity: &crate::auth::RuntimeIdentityMaterial, + ) -> Result { + let public_key = crate::auth::decode_public_key(&identity.public_key) + .map_err(|_| WorkspaceCapabilityVerificationError::TrustRecordCorrupt)?; + let fingerprint = format!("sha256:{}", hex_lower(&Sha256::digest(public_key))); + Ok(Self { + runtime_id: identity.identity_id.clone(), + public_key: identity.public_key.clone(), + public_key_fingerprint: fingerprint, + signing_key: Arc::new( + identity + .signing_key() + .map_err(|_| WorkspaceCapabilityVerificationError::TrustRecordCorrupt)?, + ), + }) + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn public_key_fingerprint(&self) -> &str { + &self.public_key_fingerprint + } + + pub fn public_key(&self) -> &str { + &self.public_key + } + + pub fn sign_response( + &self, + challenge: &WorkspaceRuntimeVerificationChallenge, + runtime_nonce: String, + now_unix: i64, + ) -> Result { + validate_verification_challenge(challenge)?; + if challenge.runtime_id != self.runtime_id + || challenge.runtime_public_key_fingerprint != self.public_key_fingerprint + || challenge.runtime_identity_revision == 0 + || challenge.expires_at <= now_unix + { + return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); + } + let unsigned = WorkspaceRuntimeVerificationResponseUnsigned { + challenge_id: challenge.challenge_id.clone(), + workspace_id: challenge.workspace_id.clone(), + runtime_id: challenge.runtime_id.clone(), + binding_revision: challenge.binding_revision, + workspace_key_id: challenge.workspace_key_id.clone(), + workspace_identity_revision: challenge.workspace_identity_revision, + workspace_trust_generation: challenge.workspace_trust_generation, + runtime_public_key_fingerprint: challenge.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: challenge.runtime_identity_revision, + workspace_nonce: challenge.workspace_nonce.clone(), + runtime_nonce, + expires_at: challenge.expires_at, + }; + let claims = RuntimeVerificationResponseClaims { + response: unsigned.clone(), + method: "POST".to_string(), + path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.to_string(), + body_digest: workspace_request_body_digest( + &serde_json::to_vec(challenge) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims)?, + ), + iat: now_unix, + exp: challenge.expires_at, + jti: uuid::Uuid::now_v7().to_string(), + }; + let proof = crate::auth::sign_json_token( + RUNTIME_VERIFICATION_TOKEN_PREFIX, + RUNTIME_VERIFICATION_SIGNING_INPUT_PREFIX, + self.signing_key.as_ref(), + &claims, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims)?; + Ok(WorkspaceRuntimeVerificationResponse { + challenge_id: unsigned.challenge_id, + workspace_id: unsigned.workspace_id, + runtime_id: unsigned.runtime_id, + binding_revision: unsigned.binding_revision, + workspace_key_id: unsigned.workspace_key_id, + workspace_identity_revision: unsigned.workspace_identity_revision, + workspace_trust_generation: unsigned.workspace_trust_generation, + runtime_public_key_fingerprint: unsigned.runtime_public_key_fingerprint, + runtime_identity_revision: unsigned.runtime_identity_revision, + workspace_nonce: unsigned.workspace_nonce, + runtime_nonce: unsigned.runtime_nonce, + expires_at: unsigned.expires_at, + response_proof: proof, + }) + } +} + +pub fn verify_runtime_verification_response( + response: &WorkspaceRuntimeVerificationResponse, + challenge: &WorkspaceRuntimeVerificationChallenge, + runtime_public_key: &str, + now_unix: i64, +) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_verification_challenge(challenge)?; + let signed = crate::auth::decode_signed_json_token::( + &response.response_proof, + RUNTIME_VERIFICATION_TOKEN_PREFIX, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedToken)?; + crate::auth::verify_signed_json_token( + RUNTIME_VERIFICATION_SIGNING_INPUT_PREFIX, + &signed.payload, + &signed.signature, + runtime_public_key, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::InvalidSignature)?; + let expected_body_digest = workspace_request_body_digest( + &serde_json::to_vec(challenge) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims)?, + ); + if signed.claims.response != response.unsigned() + || signed.claims.method != "POST" + || signed.claims.path_and_query != WORKSPACE_VERIFICATION_CHALLENGE_PATH + || signed.claims.body_digest != expected_body_digest + || signed.claims.exp != response.expires_at + || signed.claims.iat > now_unix.saturating_add(MAX_CLOCK_SKEW_SECONDS) + || signed.claims.exp <= now_unix + || response.challenge_id != challenge.challenge_id + || response.workspace_id != challenge.workspace_id + || response.runtime_id != challenge.runtime_id + || response.binding_revision != challenge.binding_revision + || response.workspace_key_id != challenge.workspace_key_id + || response.workspace_identity_revision != challenge.workspace_identity_revision + || response.workspace_trust_generation != challenge.workspace_trust_generation + || response.runtime_public_key_fingerprint != challenge.runtime_public_key_fingerprint + || response.runtime_identity_revision != challenge.runtime_identity_revision + || response.workspace_nonce != challenge.workspace_nonce + || response.runtime_nonce.is_empty() + { + return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceCapabilityClaims { + pub issuer: String, + pub issuer_workspace_id: String, + pub issuer_key_id: String, + pub issuer_identity_revision: u64, + pub trust_generation: u64, + pub binding_revision: u64, + pub runtime_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub operation: String, + pub method: String, + pub path_and_query: String, + pub body_digest: String, + pub iat: i64, + pub exp: i64, + pub jti: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkspaceCapabilityExpectation<'a> { + pub workspace_id: &'a str, + pub binding_revision: u64, + pub runtime_id: &'a str, + pub worker_id: Option<&'a str>, + pub operation: &'a str, + pub method: &'a str, + pub path_and_query: &'a str, + pub body_digest: &'a str, + pub now_unix: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedWorkspaceCapability { + pub issuer: String, + pub workspace_id: String, + pub issuer_key_id: String, + pub issuer_identity_revision: u64, + pub trust_generation: u64, + pub binding_revision: u64, + pub runtime_id: String, + pub worker_id: Option, + pub operation: String, + pub method: String, + pub path_and_query: String, + pub token_id: String, + pub expires_at: i64, +} + +pub trait WorkspaceClaimReplayProtection: Send + Sync { + fn consume_once( + &self, + workspace_id: &str, + trust_generation: u64, + token_id: &str, + expires_at: i64, + now_unix: i64, + ) -> Result; +} + +#[derive(Default)] +pub struct InMemoryWorkspaceClaimReplayProtection { + consumed: Mutex>, +} + +impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection { + fn consume_once( + &self, + workspace_id: &str, + trust_generation: u64, + token_id: &str, + expires_at: i64, + now_unix: i64, + ) -> Result { + let mut consumed = self + .consumed + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + consumed.retain(|_, expiry| *expiry > now_unix); + let key = ( + workspace_id.to_string(), + trust_generation, + token_id.to_string(), + ); + if consumed.contains_key(&key) { + return Ok(false); + } + if consumed.len() >= MAX_REPLAY_ENTRIES { + return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable); + } + consumed.insert(key, expires_at); + Ok(true) + } +} + +#[derive(Clone, Debug)] +pub struct FileWorkspaceClaimReplayProtection { + path: PathBuf, + lock: Arc>, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceClaimReplayDocument { + version: u32, + entries: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceClaimReplayEntry { + workspace_id: String, + trust_generation: u64, + token_id: String, + expires_at: i64, +} + +impl FileWorkspaceClaimReplayProtection { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Arc::new(Mutex::new(())), + } + } + + fn read(&self) -> Result { + match fs::read(&self.path) { + Ok(bytes) => { + let document: WorkspaceClaimReplayDocument = serde_json::from_slice(&bytes) + .map_err(|_| { + WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable + })?; + if document.version != 1 || document.entries.len() > MAX_REPLAY_ENTRIES { + return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable); + } + Ok(document) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(WorkspaceClaimReplayDocument { + version: 1, + entries: Vec::new(), + }) + } + Err(_) => Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable), + } + } + + fn write( + &self, + document: &WorkspaceClaimReplayDocument, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + let parent = self.path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + let bytes = serde_json::to_vec(document) + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + let temporary = parent.join(format!( + ".workspace-claim-replay-{}.tmp", + uuid::Uuid::now_v7() + )); + fs::write(&temporary, bytes) + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)) + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + } + fs::rename(&temporary, &self.path).map_err(|_| { + let _ = fs::remove_file(&temporary); + WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable + })?; + Ok(()) + } +} + +impl WorkspaceClaimReplayProtection for FileWorkspaceClaimReplayProtection { + fn consume_once( + &self, + workspace_id: &str, + trust_generation: u64, + token_id: &str, + expires_at: i64, + now_unix: i64, + ) -> Result { + let _guard = self + .lock + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + let mut document = self.read()?; + document.entries.retain(|entry| entry.expires_at > now_unix); + if document.entries.iter().any(|entry| { + entry.workspace_id == workspace_id + && entry.trust_generation == trust_generation + && entry.token_id == token_id + }) { + return Ok(false); + } + if document.entries.len() >= MAX_REPLAY_ENTRIES { + return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable); + } + document.entries.push(WorkspaceClaimReplayEntry { + workspace_id: workspace_id.to_string(), + trust_generation, + token_id: token_id.to_string(), + expires_at, + }); + self.write(&document)?; + Ok(true) + } +} + +#[derive(Clone)] +pub struct WorkspaceCapabilityVerifier { + records: Arc<[WorkspaceIssuerTrustRecord]>, + replay: Arc, +} + +impl fmt::Debug for WorkspaceCapabilityVerifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WorkspaceCapabilityVerifier") + .field("trusted_workspace_count", &self.records.len()) + .finish_non_exhaustive() + } +} + +impl WorkspaceCapabilityVerifier { + pub fn new( + records: Vec, + replay: Arc, + ) -> Result { + if records.is_empty() { + return Err(WorkspaceCapabilityVerificationError::TrustAuthorityMissing); + } + validate_workspace_issuer_trust_records(&records)?; + Ok(Self { + records: records.into(), + replay, + }) + } + + pub fn has_active_workspace_issuer(&self, workspace_id: &str) -> bool { + self.records.iter().any(|record| { + record.workspace_id == workspace_id && record.state == WorkspaceIssuerTrustState::Active + }) + } + + pub fn verify( + &self, + token: &str, + expected: &WorkspaceCapabilityExpectation<'_>, + ) -> Result { + if token.len() > MAX_TOKEN_BYTES { + return Err(WorkspaceCapabilityVerificationError::MalformedToken); + } + let signed = crate::auth::decode_signed_json_token::( + token, + WORKSPACE_TOKEN_PREFIX, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedToken)?; + let claims = signed.claims; + validate_claim_shape(&claims)?; + + if claims.issuer_workspace_id != expected.workspace_id { + return Err(WorkspaceCapabilityVerificationError::WrongWorkspace); + } + let record = self + .records + .iter() + .find(|record| record.workspace_id == claims.issuer_workspace_id) + .ok_or(WorkspaceCapabilityVerificationError::UnknownWorkspaceIssuer)?; + if record.state != WorkspaceIssuerTrustState::Active { + return Err(WorkspaceCapabilityVerificationError::IssuerRevoked); + } + if record.backend_url != claims.issuer { + return Err(WorkspaceCapabilityVerificationError::WrongIssuer); + } + if record.trust_generation != claims.trust_generation { + return Err(WorkspaceCapabilityVerificationError::StaleTrustGeneration); + } + if record.key_id != claims.issuer_key_id { + return Err(WorkspaceCapabilityVerificationError::WrongIssuerKey); + } + if record.identity_revision != claims.issuer_identity_revision { + return Err(WorkspaceCapabilityVerificationError::StaleIdentityRevision); + } + + crate::auth::verify_signed_json_token( + WORKSPACE_SIGNING_INPUT_PREFIX, + &signed.payload, + &signed.signature, + &record.public_key, + ) + .map_err(|error| match error { + RuntimeAuthError::InvalidSignature => { + WorkspaceCapabilityVerificationError::InvalidSignature + } + _ => WorkspaceCapabilityVerificationError::TrustRecordCorrupt, + })?; + + if claims.runtime_id != expected.runtime_id { + return Err(WorkspaceCapabilityVerificationError::WrongRuntime); + } + if claims.binding_revision != expected.binding_revision { + return Err(WorkspaceCapabilityVerificationError::StaleBindingRevision); + } + if claims.worker_id.as_deref() != expected.worker_id { + return Err(WorkspaceCapabilityVerificationError::WrongWorker); + } + if claims.operation != expected.operation { + return Err(WorkspaceCapabilityVerificationError::WrongOperation); + } + if claims.method != expected.method { + return Err(WorkspaceCapabilityVerificationError::WrongMethod); + } + if claims.path_and_query != expected.path_and_query { + return Err(WorkspaceCapabilityVerificationError::WrongPathAndQuery); + } + if claims.body_digest != expected.body_digest { + return Err(WorkspaceCapabilityVerificationError::WrongBodyDigest); + } + if claims.exp <= expected.now_unix { + return Err(WorkspaceCapabilityVerificationError::Expired); + } + if claims.iat > expected.now_unix.saturating_add(MAX_CLOCK_SKEW_SECONDS) { + return Err(WorkspaceCapabilityVerificationError::IssuedInFuture); + } + if claims.exp <= claims.iat + || claims.exp.saturating_sub(claims.iat) > MAX_TOKEN_LIFETIME_SECONDS + { + return Err(WorkspaceCapabilityVerificationError::InvalidLifetime); + } + if !self.replay.consume_once( + &claims.issuer_workspace_id, + claims.trust_generation, + &claims.jti, + claims.exp, + expected.now_unix, + )? { + return Err(WorkspaceCapabilityVerificationError::Replay); + } + + Ok(VerifiedWorkspaceCapability { + issuer: claims.issuer, + workspace_id: claims.issuer_workspace_id, + issuer_key_id: claims.issuer_key_id, + issuer_identity_revision: claims.issuer_identity_revision, + trust_generation: claims.trust_generation, + binding_revision: claims.binding_revision, + runtime_id: claims.runtime_id, + worker_id: claims.worker_id, + operation: claims.operation, + method: claims.method, + path_and_query: claims.path_and_query, + token_id: claims.jti, + expires_at: claims.exp, + }) + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WorkspaceCapabilityVerificationError { + #[error("Workspace issuer trust authority is not configured")] + TrustAuthorityMissing, + #[error("Workspace issuer trust contains duplicate Workspace identities")] + DuplicateWorkspaceTrust, + #[error("Workspace issuer trust contains too many records")] + TrustRecordLimitExceeded, + #[error("Workspace issuer trust record is corrupt")] + TrustRecordCorrupt, + #[error("Workspace capability token is malformed")] + MalformedToken, + #[error("Workspace capability claims are malformed")] + MalformedClaims, + #[error("Workspace capability claim identifier is invalid")] + InvalidIdentifier, + #[error("Workspace capability claim body digest is invalid")] + InvalidBodyDigest, + #[error("Workspace capability issuer is not trusted")] + UnknownWorkspaceIssuer, + #[error("Workspace capability issuer does not match trust")] + WrongIssuer, + #[error("Workspace capability issuer trust is revoked")] + IssuerRevoked, + #[error("Workspace capability trust generation is stale")] + StaleTrustGeneration, + #[error("Workspace capability issuer key does not match trust")] + WrongIssuerKey, + #[error("Workspace capability identity revision is stale")] + StaleIdentityRevision, + #[error("Workspace capability signature is invalid")] + InvalidSignature, + #[error("Workspace capability targets another Workspace")] + WrongWorkspace, + #[error("Workspace capability targets another Runtime")] + WrongRuntime, + #[error("Workspace capability binding revision is stale")] + StaleBindingRevision, + #[error("Workspace capability targets another Worker")] + WrongWorker, + #[error("Workspace capability does not authorize this operation")] + WrongOperation, + #[error("Workspace capability does not bind this HTTP method")] + WrongMethod, + #[error("Workspace capability does not bind this path and query")] + WrongPathAndQuery, + #[error("Workspace capability does not bind this request body")] + WrongBodyDigest, + #[error("Workspace capability has expired")] + Expired, + #[error("Workspace capability was issued in the future")] + IssuedInFuture, + #[error("Workspace capability lifetime is invalid")] + InvalidLifetime, + #[error("Workspace capability was already consumed")] + Replay, + #[error("Workspace capability replay authority is unavailable")] + ReplayAuthorityUnavailable, + #[error("Workspace Runtime verification authority is unavailable")] + VerificationAuthorityUnavailable, + #[error( + "Workspace Runtime verification challenge or response does not match current authority" + )] + VerificationChallengeMismatch, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkspaceCapabilitySigningInput { + payload: String, + bytes: Vec, +} + +impl WorkspaceCapabilitySigningInput { + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +pub fn workspace_capability_signing_input( + claims: &WorkspaceCapabilityClaims, +) -> Result { + validate_claim_shape(claims)?; + let encoded = serde_json::to_vec(claims) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims)?; + let payload = URL_SAFE_NO_PAD.encode(encoded); + let bytes = format!("{WORKSPACE_SIGNING_INPUT_PREFIX}{payload}").into_bytes(); + Ok(WorkspaceCapabilitySigningInput { payload, bytes }) +} + +pub fn assemble_workspace_capability_token( + input: WorkspaceCapabilitySigningInput, + signature: &[u8], +) -> Result { + if signature.len() != 64 { + return Err(WorkspaceCapabilityVerificationError::InvalidSignature); + } + Ok(format!( + "{WORKSPACE_TOKEN_PREFIX}.{}.{}", + input.payload, + URL_SAFE_NO_PAD.encode(signature) + )) +} + +pub fn inspect_workspace_capability_claims( + token: &str, +) -> Result { + if token.len() > MAX_TOKEN_BYTES { + return Err(WorkspaceCapabilityVerificationError::MalformedToken); + } + let signed = crate::auth::decode_signed_json_token::( + token, + WORKSPACE_TOKEN_PREFIX, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedToken)?; + validate_claim_shape(&signed.claims)?; + Ok(signed.claims) +} + +pub fn issue_workspace_capability_token( + signing_key: &Ed25519KeyPair, + claims: &WorkspaceCapabilityClaims, +) -> Result { + let input = workspace_capability_signing_input(claims)?; + let signature = signing_key.sign(input.bytes()); + assemble_workspace_capability_token(input, signature.as_ref()) +} + +fn validate_trust_record( + record: &WorkspaceIssuerTrustRecord, +) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_bundle(&WorkspacePublicIdentityBundle { + workspace_id: record.workspace_id.clone(), + backend_url: record.backend_url.clone(), + key_id: record.key_id.clone(), + algorithm: record.algorithm.clone(), + public_key: record.public_key.clone(), + public_key_fingerprint: record.public_key_fingerprint.clone(), + revision: record.identity_revision, + }) + .map_err(|_| WorkspaceCapabilityVerificationError::TrustRecordCorrupt)?; + if record.trust_generation == 0 { + return Err(WorkspaceCapabilityVerificationError::TrustRecordCorrupt); + } + Ok(()) +} + +fn validate_verification_record( + record: &WorkspaceRuntimeVerificationRecord, +) -> Result<(), WorkspaceCapabilityVerificationError> { + for value in [ + record.workspace_id.as_str(), + record.runtime_id.as_str(), + record.workspace_key_id.as_str(), + ] { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.trim() != value + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) + { + return Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable); + } + } + if record.binding_revision == 0 + || record.workspace_identity_revision == 0 + || record.workspace_trust_generation == 0 + || record.runtime_identity_revision == 0 + || record.verified_at <= 0 + || record.runtime_public_key_fingerprint.len() > 128 + || !record.runtime_public_key_fingerprint.starts_with("sha256:") + { + return Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable); + } + Ok(()) +} + +fn validate_verification_challenge( + challenge: &WorkspaceRuntimeVerificationChallenge, +) -> Result<(), WorkspaceCapabilityVerificationError> { + for value in [ + challenge.challenge_id.as_str(), + challenge.workspace_id.as_str(), + challenge.runtime_id.as_str(), + challenge.workspace_key_id.as_str(), + challenge.workspace_nonce.as_str(), + ] { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.trim() != value + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) + { + return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); + } + } + if challenge.binding_revision == 0 + || challenge.workspace_identity_revision == 0 + || challenge.workspace_trust_generation == 0 + || challenge.runtime_identity_revision == 0 + || challenge.runtime_public_key_fingerprint.len() > 128 + || !challenge + .runtime_public_key_fingerprint + .starts_with("sha256:") + { + return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); + } + Ok(()) +} + +fn validate_claim_shape( + claims: &WorkspaceCapabilityClaims, +) -> Result<(), WorkspaceCapabilityVerificationError> { + if claims.issuer.len() > MAX_ISSUER_BYTES + || claims.issuer.trim() != claims.issuer + || claims.issuer.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::WrongIssuer); + } + let issuer = url::Url::parse(&claims.issuer) + .map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?; + if !matches!(issuer.scheme(), "http" | "https") + || issuer.host_str().is_none() + || !issuer.username().is_empty() + || issuer.password().is_some() + || issuer.query().is_some() + || issuer.fragment().is_some() + { + return Err(WorkspaceCapabilityVerificationError::WrongIssuer); + } + for value in [ + claims.issuer_workspace_id.as_str(), + claims.issuer_key_id.as_str(), + claims.runtime_id.as_str(), + claims.jti.as_str(), + ] { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.trim() != value + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) + { + return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); + } + } + if let Some(worker_id) = &claims.worker_id { + if worker_id.is_empty() + || worker_id.len() > MAX_ID_BYTES + || worker_id.trim() != worker_id + || !worker_id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) + { + return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); + } + } + if claims.issuer_identity_revision == 0 + || claims.trust_generation == 0 + || claims.binding_revision == 0 + { + return Err(WorkspaceCapabilityVerificationError::MalformedClaims); + } + if claims.operation.is_empty() + || claims.operation.len() > MAX_OPERATION_BYTES + || claims.operation.trim() != claims.operation + || claims.operation.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::MalformedClaims); + } + if !matches!(claims.method.as_str(), "GET" | "POST" | "DELETE") { + return Err(WorkspaceCapabilityVerificationError::MalformedClaims); + } + if claims.path_and_query.is_empty() + || claims.path_and_query.len() > MAX_PATH_AND_QUERY_BYTES + || !claims.path_and_query.starts_with('/') + || claims.path_and_query.contains('#') + || claims.path_and_query.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::MalformedClaims); + } + if !is_sha256_digest(&claims.body_digest) { + return Err(WorkspaceCapabilityVerificationError::InvalidBodyDigest); + } + Ok(()) +} + +fn is_sha256_digest(value: &str) -> bool { + crate::auth::is_request_body_digest(value) +} + +pub fn workspace_request_body_digest(body: &[u8]) -> String { + crate::auth::request_body_digest(body) +} + +pub(crate) fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::RuntimeIdentityMaterial; + + fn identity( + workspace_id: &str, + key_id: &str, + revision: u64, + ) -> (Ed25519KeyPair, WorkspacePublicIdentityBundle) { + let material = RuntimeIdentityMaterial::generate(key_id).unwrap(); + let signing_key = material.signing_key().unwrap(); + let public_key_fingerprint = format!( + "sha256:{}", + hex_lower(&Sha256::digest( + crate::auth::decode_public_key(&material.public_key).unwrap() + )) + ); + ( + signing_key, + WorkspacePublicIdentityBundle { + workspace_id: workspace_id.to_string(), + backend_url: "https://backend.example.test".to_string(), + key_id: key_id.to_string(), + algorithm: WORKSPACE_SIGNING_ALGORITHM.to_string(), + public_key: material.public_key, + public_key_fingerprint, + revision, + }, + ) + } + + fn claims(record: &WorkspaceIssuerTrustRecord, jti: &str) -> WorkspaceCapabilityClaims { + WorkspaceCapabilityClaims { + issuer: record.backend_url.clone(), + issuer_workspace_id: record.workspace_id.clone(), + issuer_key_id: record.key_id.clone(), + issuer_identity_revision: record.identity_revision, + trust_generation: record.trust_generation, + binding_revision: 7, + runtime_id: "runtime-1".to_string(), + worker_id: Some("worker-1".to_string()), + operation: "worker.submit".to_string(), + method: "POST".to_string(), + path_and_query: "/v1/workers/worker-1/submit".to_string(), + body_digest: workspace_request_body_digest(br#"{"content":"hello"}"#), + iat: 1_000, + exp: 1_060, + jti: jti.to_string(), + } + } + + fn expectation<'a>(body_digest: &'a str) -> WorkspaceCapabilityExpectation<'a> { + WorkspaceCapabilityExpectation { + workspace_id: "workspace-1", + binding_revision: 7, + runtime_id: "runtime-1", + worker_id: Some("worker-1"), + operation: "worker.submit", + method: "POST", + path_and_query: "/v1/workers/worker-1/submit", + body_digest, + now_unix: 1_001, + } + } + + #[test] + fn trust_mutations_are_idempotent_generation_fenced_and_reject_stale_keys() { + let (_, bundle_v1) = identity("workspace-1", "WK-1", 1); + let mut records = Vec::new(); + let (mutation, first) = + add_workspace_issuer_trust(&mut records, bundle_v1.clone(), 10).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Added); + assert_eq!(first.trust_generation, 1); + let (mutation, replay) = + add_workspace_issuer_trust(&mut records, bundle_v1.clone(), 11).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Unchanged); + assert_eq!(replay, first); + + let oversized_records = + vec![first.clone(); MAX_WORKSPACE_ISSUER_TRUST_RECORDS.saturating_add(1)]; + assert_eq!( + validate_workspace_issuer_trust_records(&oversized_records).unwrap_err(), + WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded + ); + + let (_, stale_other_key) = identity("workspace-1", "WK-stale", 1); + assert_eq!( + replace_workspace_issuer_trust(&mut records, stale_other_key, 12).unwrap_err(), + WorkspaceIssuerTrustError::StaleIdentityRevision + ); + let (_, bundle_v2) = identity("workspace-1", "WK-2", 2); + let (mutation, replaced) = + replace_workspace_issuer_trust(&mut records, bundle_v2, 13).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Replaced); + assert_eq!(replaced.trust_generation, 2); + assert_eq!(replaced.registered_at_unix, 10); + let (_, revoked) = revoke_workspace_issuer_trust(&mut records, "workspace-1", 14).unwrap(); + assert_eq!(revoked.trust_generation, 3); + assert_eq!(revoked.state, WorkspaceIssuerTrustState::Revoked); + let (mutation, same) = + revoke_workspace_issuer_trust(&mut records, "workspace-1", 15).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Unchanged); + assert_eq!(same, revoked); + } + + #[test] + fn verifier_binds_workspace_key_generation_runtime_worker_operation_and_body() { + let (key_1, bundle_1) = identity("workspace-1", "WK-1", 1); + let (key_2, bundle_2) = identity("workspace-2", "WK-2", 1); + let record_1 = WorkspaceIssuerTrustRecord::from_bundle(bundle_1, 1, 1).unwrap(); + let record_2 = WorkspaceIssuerTrustRecord::from_bundle(bundle_2, 1, 1).unwrap(); + let verifier = WorkspaceCapabilityVerifier::new( + vec![record_1.clone(), record_2.clone()], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let expected_body = workspace_request_body_digest(br#"{"content":"hello"}"#); + let token = + issue_workspace_capability_token(&key_1, &claims(&record_1, "token-ok")).unwrap(); + assert_eq!( + verifier + .verify(&token, &expectation(&expected_body)) + .unwrap() + .workspace_id, + "workspace-1" + ); + + let workspace_2_token = + issue_workspace_capability_token(&key_2, &claims(&record_2, "workspace-2")).unwrap(); + assert_eq!( + verifier + .verify(&workspace_2_token, &expectation(&expected_body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::WrongWorkspace + ); + let workspace_2_expectation = WorkspaceCapabilityExpectation { + workspace_id: "workspace-2", + binding_revision: 7, + runtime_id: "runtime-1", + worker_id: Some("worker-1"), + operation: "worker.submit", + method: "POST", + path_and_query: "/v1/workers/worker-1/submit", + body_digest: &expected_body, + now_unix: 1_001, + }; + assert_eq!( + verifier + .verify(&workspace_2_token, &workspace_2_expectation) + .unwrap() + .workspace_id, + "workspace-2" + ); + + let wrong_key_token = + issue_workspace_capability_token(&key_2, &claims(&record_1, "wrong-key")).unwrap(); + assert_eq!( + verifier + .verify(&wrong_key_token, &expectation(&expected_body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::InvalidSignature + ); + + type ClaimsMutation = fn(&mut WorkspaceCapabilityClaims); + for (name, mutate, expected) in [ + ( + "runtime", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.runtime_id = "runtime-2".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongRuntime, + ), + ( + "worker", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.worker_id = Some("worker-2".to_string()) + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongWorker, + ), + ( + "binding", + (|claims: &mut WorkspaceCapabilityClaims| claims.binding_revision = 8) + as ClaimsMutation, + WorkspaceCapabilityVerificationError::StaleBindingRevision, + ), + ( + "issuer", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.issuer = "https://other-backend.example.test".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongIssuer, + ), + ( + "method", + (|claims: &mut WorkspaceCapabilityClaims| claims.method = "DELETE".to_string()) + as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongMethod, + ), + ( + "path_and_query", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.path_and_query = "/v1/workers/worker-1/submit?retry=1".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongPathAndQuery, + ), + ( + "operation", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.operation = "DELETE /v1/workers/worker-1".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongOperation, + ), + ] { + let mut changed = claims(&record_1, name); + mutate(&mut changed); + let token = issue_workspace_capability_token(&key_1, &changed).unwrap(); + assert_eq!( + verifier + .verify(&token, &expectation(&expected_body)) + .unwrap_err(), + expected + ); + } + let token = + issue_workspace_capability_token(&key_1, &claims(&record_1, "wrong-body")).unwrap(); + let wrong_body = workspace_request_body_digest(b"different"); + assert_eq!( + verifier + .verify(&token, &expectation(&wrong_body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::WrongBodyDigest + ); + } + + #[test] + fn verifier_rejects_replay_revocation_stale_generation_expiry_and_future_claims() { + let (key, bundle) = identity("workspace-1", "WK-1", 1); + let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 2, 1).unwrap(); + let replay = Arc::new(InMemoryWorkspaceClaimReplayProtection::default()); + let verifier = WorkspaceCapabilityVerifier::new(vec![record.clone()], replay).unwrap(); + let body = workspace_request_body_digest(br#"{"content":"hello"}"#); + let token = issue_workspace_capability_token(&key, &claims(&record, "replay")).unwrap(); + verifier.verify(&token, &expectation(&body)).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::Replay + ); + + let mut stale = claims(&record, "stale"); + stale.trust_generation = 1; + let token = issue_workspace_capability_token(&key, &stale).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::StaleTrustGeneration + ); + + let mut expired = claims(&record, "expired"); + expired.iat = 900; + expired.exp = 950; + let token = issue_workspace_capability_token(&key, &expired).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::Expired + ); + + let mut future = claims(&record, "future"); + future.iat = 1_100; + future.exp = 1_160; + let token = issue_workspace_capability_token(&key, &future).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::IssuedInFuture + ); + + let mut revoked = record; + revoked.state = WorkspaceIssuerTrustState::Revoked; + revoked.trust_generation += 1; + let revoked_verifier = WorkspaceCapabilityVerifier::new( + vec![revoked.clone()], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let token = issue_workspace_capability_token(&key, &claims(&revoked, "revoked")).unwrap(); + assert_eq!( + revoked_verifier + .verify(&token, &expectation(&body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::IssuerRevoked + ); + } + + #[test] + fn file_replay_protection_survives_reconstruction() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("workspace-replay.json"); + let first = FileWorkspaceClaimReplayProtection::new(&path); + assert!( + first + .consume_once("workspace-a", 3, "token-a", 1_000, 100) + .unwrap() + ); + let restored = FileWorkspaceClaimReplayProtection::new(&path); + assert!( + !restored + .consume_once("workspace-a", 3, "token-a", 1_000, 101) + .unwrap() + ); + assert!( + restored + .consume_once("workspace-a", 4, "token-a", 1_000, 101) + .unwrap() + ); + } + + #[test] + fn file_runtime_verification_authority_survives_reconstruction() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("workspace-verifications.json"); + let authority = FileWorkspaceRuntimeVerificationAuthority::new(&path); + let record = WorkspaceRuntimeVerificationRecord { + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-a".to_string(), + binding_revision: 7, + workspace_key_id: "WK-a".to_string(), + workspace_identity_revision: 2, + workspace_trust_generation: 3, + runtime_public_key_fingerprint: "sha256:runtime".to_string(), + runtime_identity_revision: 1, + verified_at: 100, + }; + authority.record(record.clone()).unwrap(); + let restored = FileWorkspaceRuntimeVerificationAuthority::new(&path); + assert_eq!( + restored.get("workspace-a", "runtime-a").unwrap(), + Some(record) + ); + } + + #[test] + fn verification_response_binds_the_exact_challenge_and_runtime_identity() { + let runtime_identity = RuntimeIdentityMaterial::generate("runtime-1").unwrap(); + let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(); + let public_key_fingerprint = format!( + "sha256:{}", + hex_lower(&Sha256::digest( + crate::auth::decode_public_key(&runtime_identity.public_key).unwrap() + )) + ); + let challenge = WorkspaceRuntimeVerificationChallenge { + challenge_id: "challenge-1".to_string(), + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-1".to_string(), + binding_revision: 7, + workspace_key_id: "WK-1".to_string(), + workspace_identity_revision: 2, + workspace_trust_generation: 3, + runtime_public_key_fingerprint: public_key_fingerprint, + runtime_identity_revision: 1, + workspace_nonce: "workspace-nonce".to_string(), + expires_at: 1_100, + }; + let response = signer + .sign_response(&challenge, "runtime-nonce".to_string(), 1_000) + .unwrap(); + verify_runtime_verification_response( + &response, + &challenge, + &runtime_identity.public_key, + 1_001, + ) + .unwrap(); + + let mut tampered = response.clone(); + tampered.binding_revision += 1; + assert_eq!( + verify_runtime_verification_response( + &tampered, + &challenge, + &runtime_identity.public_key, + 1_001, + ), + Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch) + ); + } +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index e52b45e8..3dd32212 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse { pub diagnostics: Vec, } +/// Lifecycle state for a Workspace-scoped Ed25519 signing identity. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSigningIdentityState { + PendingProvisioning, + Active, +} + +/// Public metadata for a Workspace signing identity. Private material and its +/// storage reference are deliberately not part of this wire authority. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSigningIdentityPublic { + pub workspace_id: String, + pub key_id: String, + pub algorithm: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub public_key_fingerprint: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, + pub state: WorkspaceSigningIdentityState, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub provisioned_at: Option, +} + +/// Copyable public trust bundle consumed by future Runtime enrollment work. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspacePublicIdentityBundle { + pub workspace_id: String, + pub backend_url: String, + pub key_id: String, + pub algorithm: String, + pub public_key: String, + pub public_key_fingerprint: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSigningIdentityResponse { + pub identity: WorkspaceSigningIdentityPublic, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub public_bundle: Option, +} + pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; @@ -1522,6 +1580,71 @@ pub struct RuntimeSummary { pub diagnostics: Vec, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeBindingState { + Configured, + Verified, + Revoked, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeConnectionDisplayState { + Configured, + Verified, + Unavailable, + Revoked, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeVerificationOutcome { + Verified, + ChallengeIssued, + VerificationFailed, + ConnectivityFailed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeVerificationEvidenceSummary { + pub verified_at: Option, + pub last_checked_at: String, + pub last_outcome: RuntimeVerificationOutcome, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub binding_revision: u64, + pub workspace_key_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub workspace_identity_revision: u64, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub runtime_identity_revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeBindingSummary { + pub state: WorkspaceRuntimeBindingState, + pub connection_state: RuntimeConnectionDisplayState, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_key_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub workspace_key_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -1531,6 +1654,8 @@ pub struct RuntimeManagementSummary { pub removable: bool, pub endpoint_configured: bool, pub token_ref_configured: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1613,16 +1738,6 @@ pub struct RuntimeTrustKeyRevealResponse { pub public_key: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(deny_unknown_fields)] -pub struct PutRuntimeTrustKeyRequest { - pub public_key: String, - #[serde(default)] - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - pub expected_revision: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -1653,12 +1768,24 @@ pub struct RuntimeTrustConflictResponse { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimePublicIdentityBundle { + pub identity_id: String, + pub public_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct CreateRemoteRuntimeRequest { - pub runtime_id: String, + pub public_bundle: RuntimePublicIdentityBundle, + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, pub endpoint: String, - pub token_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub expected_revision: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1690,6 +1817,10 @@ pub enum RuntimeConnectionTestFailureKind { pub struct RuntimeConnectionTestResponse { pub workspace_id: String, pub runtime_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub binding_revision: u64, + pub connection_state: RuntimeConnectionDisplayState, + pub verification: Option, pub checked_at: String, pub status: RuntimeConnectionTestStatus, pub failure_kind: Option, @@ -2848,6 +2979,10 @@ pub fn catalog_typescript() -> String { WorkspaceMetadataSettingsResponse::decl(&config), UpdateWorkspaceMetadataRequest::decl(&config), WorkspaceMetadataMutationResponse::decl(&config), + WorkspaceSigningIdentityState::decl(&config), + WorkspaceSigningIdentityPublic::decl(&config), + WorkspacePublicIdentityBundle::decl(&config), + WorkspaceSigningIdentityResponse::decl(&config), ProfileSettingsResponse::decl(&config), WorkspaceProfileSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config), @@ -2868,6 +3003,11 @@ pub fn catalog_typescript() -> String { RuntimeIdentityAuthority::decl(&config), RuntimeSourceSummary::decl(&config), RuntimeSummary::decl(&config), + WorkspaceRuntimeBindingState::decl(&config), + RuntimeConnectionDisplayState::decl(&config), + RuntimeVerificationOutcome::decl(&config), + RuntimeVerificationEvidenceSummary::decl(&config), + WorkspaceRuntimeBindingSummary::decl(&config), RuntimeManagementSummary::decl(&config), WorkspaceRuntimeResource::decl(&config), RuntimeTrustKeyStatus::decl(&config), @@ -2876,10 +3016,11 @@ pub fn catalog_typescript() -> String { RuntimeTrustAuditEntry::decl(&config), WorkspaceRuntimeDetail::decl(&config), RuntimeTrustKeyRevealResponse::decl(&config), - PutRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config), RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictResponse::decl(&config), + RuntimePublicIdentityBundle::decl(&config), + CreateRemoteRuntimeRequest::decl(&config), RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestResponse::decl(&config), @@ -3707,14 +3848,6 @@ mod tests { })) .is_err() ); - assert!( - serde_json::from_value::(serde_json::json!({ - "public_key": "key", - "expected_revision": 1, - "replace": true - })) - .is_err() - ); assert!( serde_json::from_value::(serde_json::json!({ "expected_revision": 1, @@ -3729,6 +3862,9 @@ mod tests { let compatible = serde_json::json!({ "workspace_id": "workspace-test", "runtime_id": "runtime-test", + "binding_revision": 3, + "connection_state": "verified", + "verification": null, "checked_at": "2026-09-01T12:00:00Z", "status": "compatible", "failure_kind": null, @@ -3877,6 +4013,45 @@ mod tests { ); } + #[test] + fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() { + let response = WorkspaceSigningIdentityResponse { + identity: WorkspaceSigningIdentityPublic { + workspace_id: "workspace-test".to_string(), + key_id: "WK-test".to_string(), + algorithm: "ed25519".to_string(), + public_key: None, + public_key_fingerprint: None, + revision: 1, + state: WorkspaceSigningIdentityState::PendingProvisioning, + created_at: "2026-01-01T00:00:00Z".to_string(), + provisioned_at: None, + }, + public_bundle: None, + }; + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!( + encoded, + serde_json::json!({ + "identity": { + "workspace_id": "workspace-test", + "key_id": "WK-test", + "algorithm": "ed25519", + "revision": 1, + "state": "pending_provisioning", + "created_at": "2026-01-01T00:00:00Z" + } + }) + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "identity": encoded["identity"].clone(), + "private_material_ref": "must-not-cross-the-wire" + })) + .is_err() + ); + } + fn companion_worker() -> WorkspaceWorkerDiscoveryItem { WorkspaceWorkerDiscoveryItem { subject: WorkspaceWorkerSubject::RuntimeWorker { diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 2b6dfff3..e4e58940 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -38,7 +38,7 @@ memory.workspace = true merge-request.workspace = true tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } tower.workspace = true -tokio-tungstenite.workspace = true +tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } worker.workspace = true workspace-api.workspace = true workdir = { workspace = true, features = ["http-client"] } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 27d1c4e6..6f128302 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -2,6 +2,10 @@ use crate::Error; use crate::resource_broker::BackendResourceBroker; #[cfg(test)] use crate::resource_broker::BackendResourceTarget; +use crate::store::{ + ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState, +}; +use crate::workspace_signing_identity::WorkspaceSigningIdentityService; use chrono::Utc; use protocol::Segment; use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder}; @@ -14,6 +18,7 @@ use std::{ error::Error as _, future::Future, io::Read as _, + net::{IpAddr, SocketAddr, ToSocketAddrs}, path::PathBuf, pin::Pin, sync::{Arc, RwLock}, @@ -24,7 +29,6 @@ use workdir::{ http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization}, }; use worker_runtime::RuntimeWorkspaceScope; -use worker_runtime::auth::{CapabilityTokenSigner, capability_claims}; use worker_runtime::catalog::{ ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest, @@ -63,12 +67,18 @@ use worker_runtime::profile_archive::ProfileSourceArchive; use worker_runtime::retention::{ WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory, }; +use worker_runtime::workspace_issuer::{ + WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement, + WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt, + WorkspaceRuntimeVerificationResponse, workspace_request_body_digest, +}; pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host"; const MAX_DIAGNOSTICS: usize = 16; const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024; +const MAX_REMOTE_RUNTIME_RESPONSE_BYTES: usize = 16 * 1024 * 1024; // Runtime creation can spend up to 60s bootstrapping; durable Submit // acceptance is acknowledged before the potentially long run preparation. const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80); @@ -816,6 +826,32 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { )) } + fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {} + + fn send_workspace_verification_challenge( + &self, + _challenge: &WorkspaceRuntimeVerificationChallenge, + _bearer_token: &str, + ) -> Result { + Err(RuntimePingFailure::new( + RuntimePingFailureKind::Unsupported, + "runtime_workspace_verification_unsupported", + "Workspace Runtime verification is unavailable for this Runtime provider", + )) + } + + fn send_workspace_verification_acknowledgement( + &self, + _acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement, + _bearer_token: &str, + ) -> Result { + Err(RuntimePingFailure::new( + RuntimePingFailureKind::Unsupported, + "runtime_workspace_verification_unsupported", + "Workspace Runtime verification is unavailable for this Runtime provider", + )) + } + fn list_hosts(&self, limit: usize) -> RuntimeList; fn list_workers(&self, limit: usize) -> RuntimeList; @@ -1875,6 +1911,48 @@ impl RuntimeRegistry { runtime.ping() } + pub fn activate_workspace_authorization( + &self, + runtime_id: &str, + binding: crate::store::WorkspaceRuntimeBinding, + ) -> Result<(), RuntimeRegistryError> { + self.runtime(runtime_id)? + .activate_workspace_authorization(binding); + Ok(()) + } + + pub fn send_workspace_verification_challenge( + &self, + runtime_id: &str, + challenge: &WorkspaceRuntimeVerificationChallenge, + bearer_token: &str, + ) -> Result { + let runtime = self.runtime(runtime_id).map_err(|_| { + RuntimePingFailure::new( + RuntimePingFailureKind::Configuration, + "runtime_verification_registration_unavailable", + "Registered Runtime binding is unavailable", + ) + })?; + runtime.send_workspace_verification_challenge(challenge, bearer_token) + } + + pub fn send_workspace_verification_acknowledgement( + &self, + runtime_id: &str, + acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement, + bearer_token: &str, + ) -> Result { + let runtime = self.runtime(runtime_id).map_err(|_| { + RuntimePingFailure::new( + RuntimePingFailureKind::Configuration, + "runtime_verification_registration_unavailable", + "Registered Runtime binding is unavailable", + ) + })?; + runtime.send_workspace_verification_acknowledgement(acknowledgement, bearer_token) + } + fn runtimes_snapshot(&self) -> Vec> { self.runtimes .read() @@ -2848,7 +2926,8 @@ pub struct RemoteRuntimeConfig { pub display_name: String, pub base_url: String, pub bearer_token: Option, - pub auth: Option, + pub workspace_authorization: Option, + pub strict_public_egress: bool, pub cached_worker_creation_available: bool, pub cached_os: String, pub cached_arch: String, @@ -2856,10 +2935,166 @@ pub struct RemoteRuntimeConfig { pub timeout: Duration, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RemoteRuntimeAuthConfig { - pub server_id: String, - pub server_private_key: String, +#[derive(Clone)] +pub struct WorkspaceRuntimeAuthorization { + store: Arc, + signing_identities: WorkspaceSigningIdentityService, + backend_url: String, + binding: Arc>>, +} + +impl std::fmt::Debug for WorkspaceRuntimeAuthorization { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkspaceRuntimeAuthorization") + .field("backend_url", &"") + .finish_non_exhaustive() + } +} + +impl WorkspaceRuntimeAuthorization { + pub fn new( + store: Arc, + signing_identities: WorkspaceSigningIdentityService, + backend_url: impl Into, + binding: Option, + ) -> Self { + Self { + store, + signing_identities, + backend_url: backend_url.into(), + binding: Arc::new(RwLock::new(binding)), + } + } + + fn activate(&self, binding: crate::store::WorkspaceRuntimeBinding) { + if let Ok(mut current) = self.binding.write() { + *current = Some(binding); + } + } + + pub(crate) fn issue( + &self, + method: &str, + path_and_query: &str, + operation: &str, + worker_id: Option<&str>, + body: &[u8], + ) -> Result { + let binding = self + .binding + .read() + .map_err(|_| { + diagnostic( + "workspace_runtime_authorization_unavailable", + DiagnosticSeverity::Error, + "Workspace Runtime authorization is unavailable".to_string(), + ) + })? + .clone() + .ok_or_else(|| { + diagnostic( + "workspace_runtime_verification_required", + DiagnosticSeverity::Error, + "Workspace Runtime binding is not verified".to_string(), + ) + })?; + if binding.state != WorkspaceRuntimeBindingState::Verified + || binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + || binding.revoked_at.is_some() + || !self + .store + .workspace_runtime_binding_matches(&binding) + .map_err(|error| { + diagnostic( + "workspace_runtime_authorization_unavailable", + DiagnosticSeverity::Error, + error.to_string(), + ) + })? + { + return Err(diagnostic( + "workspace_runtime_authorization_stale", + DiagnosticSeverity::Error, + "Workspace Runtime binding changed or was revoked".to_string(), + )); + } + let identity = self + .signing_identities + .get_validated(&binding.workspace_id) + .map_err(|error| { + diagnostic( + "workspace_runtime_authorization_unavailable", + DiagnosticSeverity::Error, + error.to_string(), + ) + })?; + let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| { + diagnostic( + "workspace_runtime_authorization_invalid", + DiagnosticSeverity::Error, + "Workspace Runtime binding is missing its Workspace key".to_string(), + ) + })?; + let trust_generation = binding.workspace_key_generation.ok_or_else(|| { + diagnostic( + "workspace_runtime_authorization_invalid", + DiagnosticSeverity::Error, + "Workspace Runtime binding is missing its trust generation".to_string(), + ) + })?; + if identity.state != "active" + || identity.key_id != workspace_key_id + || identity.revision != trust_generation + || !self + .store + .workspace_runtime_verification_matches( + &binding, + identity.revision, + trust_generation, + ) + .map_err(|error| { + diagnostic( + "workspace_runtime_authorization_unavailable", + DiagnosticSeverity::Error, + error.to_string(), + ) + })? + { + return Err(diagnostic( + "workspace_runtime_authorization_stale", + DiagnosticSeverity::Error, + "Workspace signing identity no longer matches the verified binding".to_string(), + )); + } + let now = Utc::now().timestamp(); + let claims = WorkspaceCapabilityClaims { + issuer: self.backend_url.clone(), + issuer_workspace_id: binding.workspace_id.clone(), + issuer_key_id: identity.key_id, + issuer_identity_revision: identity.revision, + trust_generation, + binding_revision: binding.binding_revision, + runtime_id: binding.runtime_id.clone(), + worker_id: worker_id.map(str::to_string), + operation: operation.to_string(), + method: method.to_string(), + path_and_query: path_and_query.to_string(), + body_digest: workspace_request_body_digest(body), + iat: now, + exp: now.saturating_add(60), + jti: uuid::Uuid::now_v7().to_string(), + }; + self.signing_identities + .issue_workspace_capability(&binding.workspace_id, &claims) + .map_err(|error| { + diagnostic( + "workspace_runtime_authorization_sign_failed", + DiagnosticSeverity::Error, + error.to_string(), + ) + }) + } } impl std::fmt::Debug for RemoteRuntimeConfig { @@ -2872,7 +3107,7 @@ impl std::fmt::Debug for RemoteRuntimeConfig { "bearer_token", &self.bearer_token.as_ref().map(|_| ""), ) - .field("auth", &self.auth.as_ref().map(|_| "")) + .field("strict_public_egress", &self.strict_public_egress) .field( "cached_worker_creation_available", &self.cached_worker_creation_available, @@ -2898,7 +3133,8 @@ impl RemoteRuntimeConfig { display_name: display_name.into(), base_url: base_url.into(), bearer_token, - auth: None, + workspace_authorization: None, + strict_public_egress: false, cached_worker_creation_available: false, cached_os: "unknown".to_string(), cached_arch: "unknown".to_string(), @@ -2912,8 +3148,8 @@ impl RemoteRuntimeConfig { self } - pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self { - self.auth = Some(auth); + pub fn with_strict_public_egress(mut self, strict: bool) -> Self { + self.strict_public_egress = strict; self } @@ -2930,9 +3166,7 @@ impl RemoteRuntimeConfig { #[derive(Clone)] struct RemoteWorkdirAuthorization { - runtime_id: String, - workspace_id: String, - auth: Option, + workspace_authorization: Option, fallback_bearer_token: Option, } @@ -2940,9 +3174,6 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("RemoteWorkdirAuthorization") - .field("runtime_id", &self.runtime_id) - .field("workspace_id", &self.workspace_id) - .field("auth", &self.auth.as_ref().map(|_| "capability_token")) .field( "fallback_bearer_token", &self.fallback_bearer_token.as_ref().map(|_| "configured"), @@ -2952,19 +3183,22 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization { } impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization { - fn bearer_token(&self) -> Result { - if let Some(auth) = self.auth.as_ref() { - let claims = capability_claims( - &auth.server_id, - &self.runtime_id, - &self.workspace_id, - all_remote_runtime_permissions(), - 300, - ) - .map_err(|error| WorkdirError::Unavailable(error.to_string()))?; - return CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key) - .sign(&claims) - .map_err(|error| WorkdirError::Unavailable(error.to_string())); + fn bearer_token( + &self, + method: &str, + path_and_query: &str, + body: &[u8], + ) -> Result { + if let Some(authorization) = &self.workspace_authorization { + return authorization + .issue( + method, + path_and_query, + workspace_runtime_operation(method, path_and_query), + None, + body, + ) + .map_err(|error| WorkdirError::Unavailable(error.message)); } self.fallback_bearer_token.clone().ok_or_else(|| { WorkdirError::Unavailable( @@ -2974,6 +3208,107 @@ impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization { } } +fn resolve_remote_addresses_with_timeout( + timeout: Duration, + resolver: F, +) -> Result, String> +where + F: FnOnce() -> Result, String> + Send + 'static, +{ + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("runtime-egress-dns".to_string()) + .spawn(move || { + let _ = sender.send(resolver()); + }) + .map_err(|_| "endpoint DNS resolver could not start".to_string())?; + receiver + .recv_timeout(timeout) + .map_err(|error| match error { + std::sync::mpsc::RecvTimeoutError::Timeout => { + "endpoint DNS resolution timed out".to_string() + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "endpoint DNS resolver stopped unexpectedly".to_string() + } + })? +} + +pub(crate) fn resolve_strict_remote_runtime_endpoint( + endpoint: &str, +) -> Result<(String, Vec), String> { + let endpoint = + Url::parse(endpoint).map_err(|_| "endpoint must be an absolute https URL".to_string())?; + if endpoint.scheme() != "https" + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + { + return Err( + "endpoint must be an https origin without credentials, query, or fragment".to_string(), + ); + } + let host = endpoint.host_str().expect("checked above").to_string(); + if host.eq_ignore_ascii_case("localhost") + || host.ends_with(".localhost") + || host + .parse::() + .is_ok_and(is_disallowed_remote_runtime_address) + { + return Err("endpoint host is not public".to_string()); + } + let port = endpoint.port_or_known_default().unwrap_or(443); + let resolution_host = host.clone(); + let addresses = resolve_remote_addresses_with_timeout(Duration::from_secs(3), move || { + (resolution_host.as_str(), port) + .to_socket_addrs() + .map(|addresses| addresses.collect::>()) + .map_err(|_| "endpoint DNS resolution failed".to_string()) + })?; + if addresses.is_empty() + || addresses + .iter() + .any(|address| is_disallowed_remote_runtime_address(address.ip())) + { + return Err("endpoint DNS resolution included a non-public address".to_string()); + } + Ok((host, addresses)) +} + +pub(crate) fn is_disallowed_remote_runtime_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_unspecified() + || address.is_broadcast() + || address.is_documentation() + || address.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) + || octets[0] >= 240 + } + IpAddr::V6(address) => { + let segments = address.segments(); + address.is_loopback() + || address.is_unspecified() + || address.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || address.to_ipv4_mapped().is_some_and(|address| { + is_disallowed_remote_runtime_address(IpAddr::V4(address)) + }) + } + } +} + #[derive(Clone)] pub struct RemoteWorkerRuntime { runtime_id: String, @@ -2981,7 +3316,7 @@ pub struct RemoteWorkerRuntime { base_url: String, workspace_id: String, bearer_token: Option, - auth: Option, + workspace_authorization: Option, cached_worker_creation_available: bool, cached_os: String, cached_arch: String, @@ -3035,20 +3370,66 @@ fn remote_runtime_ping_transport_failure(error: reqwest::Error) -> RuntimePingFa ) } -fn all_remote_runtime_permissions() -> Vec { - [ - "workers:list", - "workers:create", - "workers:read", - "workers:delete", - "workers:input", - "workers:stop", - "workers:protocol", - "workdirs:operate", - ] - .into_iter() - .map(str::to_string) - .collect() +fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static str { + let path = path_and_query.split('?').next().unwrap_or(path_and_query); + if path == "/v1/ping" && method == "GET" { + return RUNTIME_PING_PERMISSION; + } + if path == "/v1/workers" && method == "GET" { + return "workers:list"; + } + if path == "/v1/workers" && method == "POST" { + return "workers:create"; + } + if (path == "/v1/working-directories/repository-access" + || path == "/v1/repository-refs/observe") + && method == "POST" + { + return "workdirs:operate"; + } + if path.starts_with("/v1/workdir-sessions") + || (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions")) + { + return "workdirs:operate"; + } + if path.starts_with("/v1/config-bundles") + || path.starts_with("/v1/workspace-prompt-projections") + || path.starts_with("/v1/working-directories") + { + return "workers:create"; + } + if path.ends_with("/workspace-api") { + return "workers:create"; + } + if path.ends_with("/input") || path.ends_with("/restore") || path.contains("/attachments") { + return "workers:input"; + } + if path.ends_with("/stop") || path.ends_with("/cancel") { + return "workers:stop"; + } + if path == "/v1/protocol/ws" { + return "workers:list"; + } + if path.ends_with("/protocol") || path.ends_with("/protocol/ws") { + return "workers:protocol"; + } + if path.ends_with("/completions") { + return "workers:read"; + } + if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") { + return "workers:delete"; + } + if path.starts_with("/v1/workers/") && method == "GET" { + return "workers:read"; + } + "runtime:read" +} + +fn worker_id_from_remote_path(path_and_query: &str) -> Option { + let path = path_and_query.split('?').next().unwrap_or(path_and_query); + let rest = path.strip_prefix("/v1/workers/")?; + let worker_id = rest.split('/').next()?; + (!worker_id.is_empty()).then(|| worker_id.to_string()) } impl RemoteWorkerRuntime { @@ -3059,25 +3440,54 @@ impl RemoteWorkerRuntime { ) -> Result { validate_backend_identifier("runtime_id", &config.runtime_id)?; let base_url = config.base_url.trim_end_matches('/').to_string(); + let pinned_endpoint = if config.strict_public_egress { + Some( + resolve_strict_remote_runtime_endpoint(&base_url).map_err(|message| { + RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: config.runtime_id.clone(), + code: "remote_runtime_endpoint_not_allowed".to_string(), + message, + } + })?, + ) + } else { + None + }; let timeout = config.timeout; - let http = - run_blocking_http(move || BlockingHttpClient::builder().timeout(timeout).build()) - .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { - runtime_id: config.runtime_id.clone(), - code: "remote_runtime_client_build_failed".to_string(), - message: err.to_string(), - })?; + let blocking_resolution = pinned_endpoint.clone(); + let http = run_blocking_http(move || { + let mut builder = BlockingHttpClient::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy(); + if let Some((host, addresses)) = &blocking_resolution { + builder = builder.resolve_to_addrs(host, addresses); + } + builder.build() + }) + .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: config.runtime_id.clone(), + code: "remote_runtime_client_build_failed".to_string(), + message: err.to_string(), + })?; // Workdir command-output waits are bounded to 20 seconds by Runtime; // leave transport margin while retaining a finite client timeout. let workdir_timeout = timeout.max(Duration::from_secs(30)); - let async_http = AsyncHttpClient::builder() + let mut async_builder = AsyncHttpClient::builder() .timeout(workdir_timeout) - .build() - .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { - runtime_id: config.runtime_id.clone(), - code: "remote_runtime_async_client_build_failed".to_string(), - message: err.to_string(), - })?; + .redirect(reqwest::redirect::Policy::none()) + .no_proxy(); + if let Some((host, addresses)) = &pinned_endpoint { + async_builder = async_builder.resolve_to_addrs(host, addresses); + } + let async_http = + async_builder + .build() + .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: config.runtime_id.clone(), + code: "remote_runtime_async_client_build_failed".to_string(), + message: err.to_string(), + })?; Ok(Self { host_id: host_id_for_remote_runtime(&config.runtime_id), runtime_id: config.runtime_id, @@ -3085,7 +3495,7 @@ impl RemoteWorkerRuntime { base_url, workspace_id, bearer_token: config.bearer_token, - auth: config.auth, + workspace_authorization: config.workspace_authorization, cached_worker_creation_available: config.cached_worker_creation_available, cached_os: config.cached_os, cached_arch: config.cached_arch, @@ -3111,9 +3521,7 @@ impl RemoteWorkerRuntime { let workdir_id = Workdir::new(working_directory_id).id().clone(); let authorization: Arc = Arc::new(RemoteWorkdirAuthorization { - runtime_id: self.runtime_id.clone(), - workspace_id: self.workspace_id.clone(), - auth: self.auth.clone(), + workspace_authorization: self.workspace_authorization.clone(), fallback_bearer_token: self.bearer_token.clone(), }); RemoteWorkdirSession::open_with_authorization( @@ -3150,11 +3558,54 @@ impl RemoteWorkerRuntime { format!("{base}/v1/workers/{worker_id}/protocol/ws") } + fn post_bearer_json( + &self, + path: &str, + body: &T, + bearer_token: &str, + ) -> Result + where + T: Serialize + ?Sized, + U: DeserializeOwned, + { + let response = self + .http + .post(self.endpoint(path)) + .bearer_auth(bearer_token) + .json(body) + .send() + .map_err(|error| { + RuntimePingFailure::new( + RuntimePingFailureKind::NetworkUnreachable, + "runtime_workspace_verification_unreachable", + format!("Runtime verification request failed: {error}"), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(RuntimePingFailure::new( + RuntimePingFailureKind::MalformedResponse, + "runtime_workspace_verification_rejected", + format!( + "Runtime verification request returned HTTP {}", + status.as_u16() + ), + )); + } + response.json::().map_err(|error| { + RuntimePingFailure::new( + RuntimePingFailureKind::MalformedResponse, + "runtime_workspace_verification_invalid_response", + format!("Runtime verification response was invalid: {error}"), + ) + }) + } + fn get_json(&self, path: &str) -> Result where T: DeserializeOwned + Send + 'static, { - self.send_json(path, self.http.get(self.endpoint(path))) + self.send_json(path, "GET", &[], self.http.get(self.endpoint(path))) } fn post_json(&self, path: &str, body: &B) -> Result @@ -3162,7 +3613,22 @@ impl RemoteWorkerRuntime { B: Serialize + ?Sized, T: DeserializeOwned + Send + 'static, { - self.send_json(path, self.http.post(self.endpoint(path)).json(body)) + let body = serde_json::to_vec(body).map_err(|error| { + diagnostic( + "remote_runtime_request_encode_failed", + DiagnosticSeverity::Error, + error.to_string(), + ) + })?; + self.send_json( + path, + "POST", + &body, + self.http + .post(self.endpoint(path)) + .header(CONTENT_TYPE, "application/json") + .body(body.clone()), + ) } fn post_bytes(&self, path: &str, body: &[u8]) -> Result @@ -3171,7 +3637,12 @@ impl RemoteWorkerRuntime { { self.send_json( path, - self.http.post(self.endpoint(path)).body(body.to_vec()), + "POST", + body, + self.http + .post(self.endpoint(path)) + .header(CONTENT_TYPE, "application/json") + .body(body.to_vec()), ) } @@ -3179,55 +3650,27 @@ impl RemoteWorkerRuntime { where T: DeserializeOwned + Send + 'static, { - self.send_json(path, self.http.delete(self.endpoint(path))) - } - - fn runtime_capability_token_with_permissions( - &self, - path: &str, - permissions: Vec, - ) -> Option { - let auth = self.auth.as_ref()?; - let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key); - let claims = capability_claims( - &auth.server_id, - &self.runtime_id, - &self.workspace_id, - permissions, - 300, - ) - .map_err(|error| { - eprintln!( - "failed to build Runtime capability claims for {} {}: {error}", - self.runtime_id, path - ); - error - }) - .ok()?; - signer - .sign(&claims) - .map_err(|error| { - eprintln!( - "failed to sign Runtime capability token for {} {}: {error}", - self.runtime_id, path - ); - error - }) - .ok() - } - - fn runtime_capability_token(&self, path: &str) -> Option { - self.runtime_capability_token_with_permissions(path, all_remote_runtime_permissions()) + self.send_json(path, "DELETE", &[], self.http.delete(self.endpoint(path))) } fn ping_http(&self) -> Result { const PATH: &str = "/v1/ping"; let workspace_id = self.workspace_id.clone(); let bearer_token = self.bearer_token.clone(); - let capability_token = self.runtime_capability_token_with_permissions( - PATH, - vec![RUNTIME_PING_PERMISSION.to_string()], - ); + let capability_token = match &self.workspace_authorization { + Some(authorization) => Some( + authorization + .issue("GET", PATH, RUNTIME_PING_PERMISSION, None, &[]) + .map_err(|diagnostic| { + RuntimePingFailure::new( + RuntimePingFailureKind::Authentication, + diagnostic.code, + diagnostic.message, + ) + })?, + ), + None => None, + }; let request = self .http .get(self.endpoint(PATH)) @@ -3292,15 +3735,33 @@ impl RemoteWorkerRuntime { }) } - fn send_json(&self, path: &str, request: RequestBuilder) -> Result + fn send_json( + &self, + path: &str, + method: &str, + body: &[u8], + request: RequestBuilder, + ) -> Result where T: DeserializeOwned + Send + 'static, { let runtime_id = self.runtime_id.clone(); + let workspace_id = self.workspace_id.clone(); let bearer_token = self.bearer_token.clone(); - let capability_token = self.runtime_capability_token(path); + let capability_token = match &self.workspace_authorization { + Some(authorization) => Some(authorization.issue( + method, + path, + workspace_runtime_operation(method, path), + worker_id_from_remote_path(path).as_deref(), + body, + )?), + None => None, + }; run_blocking_http(move || { - let request = request.header(CONTENT_TYPE, "application/json"); + let request = request + .header(CONTENT_TYPE, "application/json") + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id); let request = if let Some(token) = capability_token.as_deref().or(bearer_token.as_deref()) { request.header(AUTHORIZATION, format!("Bearer {token}")) @@ -3312,12 +3773,36 @@ impl RemoteWorkerRuntime { .map_err(|err| remote_reqwest_diagnostic(&runtime_id, err))?; let status = response.status(); if status.is_success() { - response.json::().map_err(|err| { + let mut body = Vec::new(); + response + .take((MAX_REMOTE_RUNTIME_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| { + diagnostic( + "remote_runtime_response_read_failed", + DiagnosticSeverity::Error, + format!( + "Remote Runtime response could not be read for '{}'", + runtime_id + ), + ) + })?; + if body.len() > MAX_REMOTE_RUNTIME_RESPONSE_BYTES { + return Err(diagnostic( + "remote_runtime_response_too_large", + DiagnosticSeverity::Error, + format!( + "Remote Runtime response exceeded the allowed size for '{}'", + runtime_id + ), + )); + } + serde_json::from_slice::(&body).map_err(|_| { diagnostic( "remote_runtime_malformed_response", DiagnosticSeverity::Error, format!( - "Remote Runtime returned malformed JSON for '{}': {err}", + "Remote Runtime returned malformed JSON for '{}'", runtime_id ), ) @@ -3485,6 +3970,36 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { self.ping_http() } + fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) { + if let Some(authorization) = &self.workspace_authorization { + authorization.activate(binding); + } + } + + fn send_workspace_verification_challenge( + &self, + challenge: &WorkspaceRuntimeVerificationChallenge, + bearer_token: &str, + ) -> Result { + self.post_bearer_json( + worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_CHALLENGE_PATH, + challenge, + bearer_token, + ) + } + + fn send_workspace_verification_acknowledgement( + &self, + acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement, + bearer_token: &str, + ) -> Result { + self.post_bearer_json( + worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_ACK_PATH, + acknowledgement, + bearer_token, + ) + } + fn list_hosts(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); @@ -3772,12 +4287,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { workspace_api: Some(workspace_api), memory_settings: request.resolved_memory_settings.clone(), }; + let create_body = match serde_json::to_vec(&create) { + Ok(body) => body, + Err(error) => { + return WorkerSpawnResult { + state: WorkerOperationState::Rejected, + worker: None, + acceptance_evidence: Vec::new(), + diagnostics: vec![diagnostic( + "remote_runtime_request_encode_failed", + DiagnosticSeverity::Error, + error.to_string(), + )], + }; + } + }; match self.send_json::( "/v1/workers", + "POST", + &create_body, self.http .post(self.endpoint("/v1/workers")) .timeout(REMOTE_WORKER_CREATE_TIMEOUT) - .json(&create), + .header(CONTENT_TYPE, "application/json") + .body(create_body.clone()), ) { Ok(response) => WorkerSpawnResult { state: WorkerOperationState::Accepted, @@ -3926,13 +4459,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { &self, worker_id: &str, ) -> Option { + let path = format!("/v1/workers/{worker_id}/protocol/ws"); + let bearer_token = match &self.workspace_authorization { + Some(authorization) => authorization + .issue("GET", &path, "workers:protocol", Some(worker_id), &[]) + .ok(), + None => self.bearer_token.clone(), + }; Some(crate::observation::RuntimeObservationSource::remote_ws( crate::observation::RuntimeObservationSourceConfig { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id), endpoint: self.ws_endpoint(worker_id), - bearer_token: self - .runtime_capability_token(&format!("/v1/workers/{worker_id}/protocol")) - .or_else(|| self.bearer_token.clone()), + bearer_token, }, )) } @@ -4516,7 +5054,13 @@ fn remote_http_status_diagnostic( status: StatusCode, response: reqwest::blocking::Response, ) -> RuntimeDiagnostic { - let error = response.json::().ok(); + let mut body = Vec::new(); + let error = response + .take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut body) + .ok() + .filter(|_| body.len() <= MAX_RUNTIME_PING_RESPONSE_BYTES) + .and_then(|_| serde_json::from_slice::(&body).ok()); let remote_code = error .as_ref() .map(|error| error.error.code.as_str()) @@ -4734,6 +5278,53 @@ mod tests { use std::sync::{Arc, Mutex}; use std::thread; + #[test] + fn strict_remote_runtime_dns_resolution_has_a_short_timeout() { + let error = resolve_remote_addresses_with_timeout(Duration::from_millis(1), || { + std::thread::sleep(Duration::from_millis(50)); + Ok(Vec::new()) + }) + .unwrap_err(); + assert_eq!(error, "endpoint DNS resolution timed out"); + } + + #[test] + fn strict_remote_runtime_egress_rejects_disallowed_endpoint_before_client_use() { + let config = RemoteRuntimeConfig::new( + "runtime-private", + "Private Runtime", + "https://169.254.169.254/latest/meta-data", + None, + ) + .with_strict_public_egress(true); + let error = match RemoteWorkerRuntime::new( + config, + "workspace-a".to_string(), + "http://127.0.0.1:1".to_string(), + ) { + Err(error) => error, + Ok(_) => panic!("disallowed endpoint unexpectedly produced a Runtime client"), + }; + assert!(matches!( + error, + RuntimeRegistryError::RuntimeOperationFailed { code, .. } + if code == "remote_runtime_endpoint_not_allowed" + )); + } + + #[test] + fn strict_remote_runtime_egress_accepts_and_pins_public_https_address() { + let config = + RemoteRuntimeConfig::new("runtime-public", "Public Runtime", "https://8.8.8.8", None) + .with_strict_public_egress(true); + RemoteWorkerRuntime::new( + config, + "workspace-a".to_string(), + "http://127.0.0.1:1".to_string(), + ) + .unwrap(); + } + #[test] fn remote_worker_create_timeout_covers_runtime_phase_budgets() { assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5)); @@ -5948,6 +6539,19 @@ mod tests { assert!(!format!("{failure:?}").contains("secret-token")); } + #[test] + fn workspace_runtime_operation_matches_runtime_permission_classifier() { + let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string(); + assert_eq!( + workspace_runtime_operation("POST", &format!("/v1/workers/{worker_id}/workspace-api")), + "workers:create" + ); + assert_eq!( + workspace_runtime_operation("GET", &format!("/v1/workers/{worker_id}/protocol/ws")), + "workers:protocol" + ); + } + #[test] fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() { let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string(); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 7a7c2199..eca08f1f 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -441,13 +441,49 @@ CREATE TABLE workspace_runtime_bindings ( public_key TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), + state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), + authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), + workspace_key_id TEXT, + workspace_key_generation INTEGER CHECK (workspace_key_generation > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, PRIMARY KEY (workspace_id, runtime_id), UNIQUE (workspace_id, public_key_fingerprint), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + CHECK ( + (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) ); +CREATE TABLE workspace_runtime_verifications ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_revision INTEGER NOT NULL CHECK(binding_revision > 0), + workspace_key_id TEXT NOT NULL, + workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0), + workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0), + runtime_public_key_fingerprint TEXT NOT NULL, + runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0), + challenge_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), + last_outcome TEXT NOT NULL, + verified_at TEXT, + checked_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, runtime_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE, + CHECK((state = 'verified' AND verified_at IS NOT NULL) + OR (state != 'verified' AND verified_at IS NULL)) +); +CREATE INDEX workspace_runtime_verifications_state_idx + ON workspace_runtime_verifications(workspace_id, state, checked_at DESC); CREATE TABLE workspace_runtime_binding_audit ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, @@ -795,6 +831,51 @@ CREATE TABLE workspace_create_operations ( created_at TEXT NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); +CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); +CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); CREATE TABLE workspace_memory_documents ( workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, body_md TEXT NOT NULL, diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index f95318f2..fe50e5a0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -34,6 +34,7 @@ mod workdir_removal; pub mod worker_source; pub mod workspace_catalog; mod workspace_deletion; +pub mod workspace_signing_identity; mod workspace_subscription; pub use authority::{ @@ -138,6 +139,8 @@ pub enum Error { WorkerSourceIdentity(String), #[error("workspace identity error: {0}")] WorkspaceIdentity(String), + #[error("Workspace signing identity error ({code}): {message}")] + WorkspaceSigningIdentity { code: String, message: String }, #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 2c44f905..b19426da 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -1,15 +1,15 @@ -use std::collections::VecDeque; use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::ExitCode; use std::sync::Arc; use chrono::Utc; -use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; -use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; -use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding}; +use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig}; +use yoi_workspace_server::store::{ + SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, + WorkspaceRuntimeBindingState, +}; use yoi_workspace_server::{ ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, @@ -18,8 +18,6 @@ use yoi_workspace_server::{ #[derive(Debug)] enum Command { Serve(ServeOptions), - Identity(Vec), - TrustRuntime(Vec), Migrate(MigrateOptions), Skills(SkillsCommand), Help, @@ -76,8 +74,6 @@ async fn run() -> Result<(), Box> { let args = std::env::args().skip(1).collect::>(); match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, - Command::Identity(args) => run_identity_command(args), - Command::TrustRuntime(args) => run_trust_runtime_command(args), Command::Migrate(options) => run_migrate(options), Command::Skills(command) => run_skills(command), Command::Help => Ok(()), @@ -91,8 +87,6 @@ fn parse_command(args: &[String]) -> Result { }; match command.as_str() { - "identity" => Ok(Command::Identity(rest.to_vec())), - "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), "migrate" => parse_migrate_options(rest).map(Command::Migrate), "skills" => parse_skills_command(rest), "serve" => { @@ -107,371 +101,11 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `migrate`, `skills`, or `serve`" ))), } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct ServerIdentityFile { - identity: RuntimeIdentityMaterial, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct PublicIdentityView { - identity_id: String, - public_key: String, -} - -fn server_identity_path() -> PathBuf { - ServerConfig::default_server_data_root().join("identity.toml") -} - -fn read_server_identity_file( - path: &Path, -) -> Result, Box> { - if !path.exists() { - return Ok(None); - } - let contents = std::fs::read_to_string(path)?; - Ok(Some(toml::from_str(&contents)?)) -} - -fn write_server_identity_file( - path: &Path, - identity: &ServerIdentityFile, -) -> Result<(), Box> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let contents = toml::to_string_pretty(identity)?; - write_secret_file(path, contents.as_bytes())?; - Ok(()) -} - -fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - let mut file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o600) - .open(path)?; - use std::io::Write as _; - file.write_all(contents)?; - } - #[cfg(not(unix))] - { - std::fs::write(path, contents)?; - } - Ok(()) -} - -fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView { - PublicIdentityView { - identity_id: identity.identity_id.clone(), - public_key: identity.public_key.clone(), - } -} - -fn run_identity_command(args: Vec) -> Result<(), Box> { - let mut args = VecDeque::from(args); - let subcommand = args - .pop_front() - .ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?; - match subcommand.as_str() { - "init" => { - let mut server_id = None; - let mut replace = false; - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?), - "--replace" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - replace = true; - } - _ => { - return Err(Box::new(CliError(format!( - "unknown identity init argument `{flag}`" - )))); - } - } - } - let server_id = server_id - .ok_or_else(|| CliError("identity init requires --server-id".to_string()))?; - let path = server_identity_path(); - if read_server_identity_file(&path)?.is_some() && !replace { - return Err(Box::new(CliError(format!( - "server identity already exists at {}; pass --replace to rotate it", - path.display() - )))); - } - let identity = RuntimeIdentityMaterial::generate(server_id)?; - write_server_identity_file( - &path, - &ServerIdentityFile { - identity: identity.clone(), - }, - )?; - println!("server_id={}", identity.identity_id); - println!("public_key={}", identity.public_key); - println!("identity_file={}", path.display()); - Ok(()) - } - "show" => { - let mut json = false; - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--json" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - json = true; - } - _ => { - return Err(Box::new(CliError(format!( - "unknown identity show argument `{flag}`" - )))); - } - } - } - let path = server_identity_path(); - let identity = read_server_identity_file(&path)?.ok_or_else(|| { - CliError(format!( - "server identity is not initialized at {}", - path.display() - )) - })?; - let view = public_identity_view(&identity.identity); - if json { - println!("{}", serde_json::to_string_pretty(&view)?); - } else { - println!("server_id={}", view.identity_id); - println!("public_key={}", view.public_key); - println!("identity_file={}", path.display()); - } - Ok(()) - } - _ => Err(Box::new(CliError(format!( - "unknown identity subcommand `{subcommand}`" - )))), - } -} - -fn run_trust_runtime_command(args: Vec) -> Result<(), Box> { - let mut args = VecDeque::from(args); - let subcommand = args - .pop_front() - .ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?; - let database_path = ServerConfig::default_server_database_path(); - if let Some(parent) = database_path.parent() { - std::fs::create_dir_all(parent)?; - } - let store = SqliteWorkspaceStore::open(&database_path)?; - match subcommand.as_str() { - "add" => { - let mut runtime_id = None; - let mut workspace_id = None; - let mut base_url = None; - let mut public_key = None; - let mut display_name = None; - let mut replace = false; - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--runtime-id" => { - runtime_id = Some(take_value(&flag, inline_value, &mut args)?) - } - "--workspace-id" => { - workspace_id = Some(take_value(&flag, inline_value, &mut args)?) - } - "--base-url" | "--endpoint" => { - base_url = Some(take_value(&flag, inline_value, &mut args)?) - } - "--public-key" => { - public_key = Some(take_value(&flag, inline_value, &mut args)?) - } - "--display-name" => { - display_name = Some(take_value(&flag, inline_value, &mut args)?) - } - "--replace" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - replace = true; - } - _ => { - return Err(Box::new(CliError(format!( - "unknown trust-runtime add argument `{flag}`" - )))); - } - } - } - let runtime_id = runtime_id - .ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?; - let workspace_id = workspace_id - .ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?; - if !store - .list_workspaces()? - .iter() - .any(|workspace| workspace.workspace_id == workspace_id) - { - return Err(Box::new(CliError(format!( - "Workspace `{workspace_id}` is not registered" - )))); - } - let base_url = base_url - .ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?; - let public_key = public_key - .ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?; - decode_public_key(&public_key)?; - let now = Utc::now().to_rfc3339(); - let outcome = store.upsert_workspace_runtime_binding( - WorkspaceRuntimeBinding { - workspace_id: workspace_id.clone(), - runtime_id: runtime_id.clone(), - display_name: display_name.unwrap_or_else(|| runtime_id.clone()), - base_url, - public_key, - public_key_fingerprint: String::new(), - binding_revision: 1, - created_at: now.clone(), - updated_at: now, - revoked_at: None, - }, - replace, - )?; - println!("workspace_id={workspace_id}"); - println!("runtime_id={runtime_id}"); - println!( - "result={}", - match outcome { - yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created => - "created", - yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged => - "unchanged", - yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced => - "replaced", - } - ); - println!("server_db={}", database_path.display()); - Ok(()) - } - "list" => { - let mut workspace_id = None; - let mut json = false; - let mut include_revoked = false; - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--workspace-id" => { - workspace_id = Some(take_value(&flag, inline_value, &mut args)?) - } - "--json" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - json = true; - } - "--include-revoked" => { - ensure_no_inline_value(&flag, inline_value.as_deref())?; - include_revoked = true; - } - _ => { - return Err(Box::new(CliError(format!( - "unknown trust-runtime list argument `{flag}`" - )))); - } - } - } - let workspace_id = workspace_id.ok_or_else(|| { - CliError("trust-runtime list requires --workspace-id".to_string()) - })?; - let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?; - if json { - println!("{}", serde_json::to_string_pretty(&records)?); - } else { - for runtime in records { - println!( - "workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}", - runtime.workspace_id, - runtime.runtime_id, - runtime.base_url, - runtime.public_key_fingerprint, - runtime.revoked_at.unwrap_or_default() - ); - } - } - Ok(()) - } - "revoke" => { - let mut workspace_id = None; - let mut runtime_id = None; - while let Some(arg) = args.pop_front() { - let (flag, inline_value) = split_flag_value(arg)?; - match flag.as_str() { - "--workspace-id" => { - workspace_id = Some(take_value(&flag, inline_value, &mut args)?) - } - "--runtime-id" => { - runtime_id = Some(take_value(&flag, inline_value, &mut args)?) - } - _ => { - return Err(Box::new(CliError(format!( - "unknown trust-runtime revoke argument `{flag}`" - )))); - } - } - } - let workspace_id = workspace_id.ok_or_else(|| { - CliError("trust-runtime revoke requires --workspace-id".to_string()) - })?; - let runtime_id = runtime_id.ok_or_else(|| { - CliError("trust-runtime revoke requires --runtime-id".to_string()) - })?; - let now = Utc::now().to_rfc3339(); - if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? { - return Err(Box::new(CliError(format!( - "trusted runtime `{runtime_id}` is not registered or is already revoked" - )))); - } - println!("revoked_runtime_id={runtime_id}"); - Ok(()) - } - _ => Err(Box::new(CliError(format!( - "unknown trust-runtime subcommand `{subcommand}`" - )))), - } -} - -fn split_flag_value(arg: String) -> Result<(String, Option), CliError> { - if let Some((flag, value)) = arg.split_once('=') { - if flag.is_empty() { - return Err(CliError("empty flag name".to_string())); - } - Ok((flag.to_string(), Some(value.to_string()))) - } else { - Ok((arg, None)) - } -} - -fn take_value( - flag: &str, - inline_value: Option, - args: &mut VecDeque, -) -> Result { - if let Some(value) = inline_value { - return Ok(value); - } - args.pop_front() - .ok_or_else(|| CliError(format!("{flag} requires a value"))) -} - -fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> { - if inline_value.is_some() { - return Err(CliError(format!("{flag} does not accept a value"))); - } - Ok(()) -} - fn run_skills(command: SkillsCommand) -> Result<(), Box> { match command { SkillsCommand::List(options) => { @@ -519,6 +153,30 @@ fn load_skill_workspace_config( }) } +fn remote_runtime_config_from_binding( + binding: WorkspaceRuntimeBinding, +) -> Result, CliError> { + if binding.runtime_id == EMBEDDED_RUNTIME_ID { + return Ok(None); + } + if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity { + return Err(CliError(format!( + "Runtime binding '{}:{}' still uses removed legacy Server-issued authentication", + binding.workspace_id, binding.runtime_id + ))); + } + Ok(Some( + RemoteRuntimeConfig::new( + binding.runtime_id, + binding.display_name, + binding.base_url, + None, + ) + .with_workspace_id(binding.workspace_id) + .with_strict_public_egress(true), + )) +} + fn run_migrate(options: MigrateOptions) -> Result<(), Box> { if options.help { print_migrate_help(); @@ -639,6 +297,7 @@ fn append_workspace_runtime_sources( .into_iter() .filter(|binding| { binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID + && binding.state == WorkspaceRuntimeBindingState::Verified }) .collect::>() }) @@ -647,30 +306,13 @@ fn append_workspace_runtime_sources( .into_iter() .flatten() .collect::>(); - let Some(server_identity) = read_server_identity_file(&server_identity_path())? else { - if !bindings.is_empty() { - return Err(Box::new(CliError( - "Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(), - ))); - } - return Ok(()); - }; - for runtime in bindings { - let auth = RemoteRuntimeAuthConfig { - server_id: server_identity.identity.identity_id.clone(), - server_private_key: server_identity.identity.private_key.clone(), + for binding in bindings { + let Some(remote) = remote_runtime_config_from_binding(binding)? else { + continue; }; - let remote = RemoteRuntimeConfig::new( - runtime.runtime_id.clone(), - runtime.display_name, - runtime.base_url, - None, - ) - .with_workspace_id(runtime.workspace_id.clone()) - .with_auth(auth); remote_runtime_sources.retain(|existing| { - existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str()) - || existing.runtime_id != runtime.runtime_id + existing.workspace_id.as_deref() != remote.workspace_id.as_deref() + || existing.runtime_id != remote.runtime_id }); remote_runtime_sources.push(remote); } @@ -840,7 +482,7 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list --workspace-id [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id --runtime-id \n yoi-server migrate [--dry-run] [--database ]\n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database ]\n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } @@ -952,60 +594,41 @@ mod tests { "unknown serve option `--frontend=/tmp/web`" ); } - #[test] - fn server_identity_init_requires_explicit_server_id() { - let error = run_identity_command(vec!["init".to_string()]).unwrap_err(); - assert_eq!(error.to_string(), "identity init requires --server-id"); - } - - #[test] - fn runtime_binding_requires_explicit_replace_for_changed_authority() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); - rusqlite::Connection::open(&path) - .unwrap() - .execute_batch( - "INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) - VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); - INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at) - VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');", - ) - .unwrap(); - let public_key = RuntimeIdentityMaterial::generate("runtime-a") - .unwrap() - .public_key; + fn runtime_startup_rejects_legacy_server_issuer_bindings() { let binding = WorkspaceRuntimeBinding { - workspace_id: "workspace-a".to_string(), - runtime_id: "runtime-a".to_string(), - display_name: "Runtime A".to_string(), - base_url: "http://127.0.0.1:18080".to_string(), - public_key, - public_key_fingerprint: String::new(), + workspace_id: "workspace-a".to_owned(), + runtime_id: "runtime-a".to_owned(), + display_name: "Runtime A".to_owned(), + base_url: "https://runtime.example.test".to_owned(), + public_key: "unused".to_owned(), + public_key_fingerprint: "unused".to_owned(), binding_revision: 1, - created_at: "2026-07-26T00:00:00Z".to_string(), - updated_at: "2026-07-26T00:00:00Z".to_string(), + state: WorkspaceRuntimeBindingState::Verified, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, + created_at: "2026-09-01T00:00:00Z".to_owned(), + updated_at: "2026-09-01T00:00:00Z".to_owned(), revoked_at: None, }; - store - .upsert_workspace_runtime_binding(binding.clone(), false) - .unwrap(); - assert!(matches!( - store - .upsert_workspace_runtime_binding(binding.clone(), false) - .unwrap(), - yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged - )); - let mut changed = binding; - changed.base_url = "http://127.0.0.1:18081".to_string(); - assert!( - store - .upsert_workspace_runtime_binding(changed.clone(), false) - .is_err() + let error = remote_runtime_config_from_binding(binding) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication" ); - store - .upsert_workspace_runtime_binding(changed, true) - .unwrap(); + } + + #[test] + fn parse_cli_rejects_removed_server_global_runtime_trust_commands() { + for command in ["identity", "trust-runtime"] { + let error = parse_command(&[command.to_owned()]).unwrap_err(); + assert_eq!( + error.to_string(), + format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`") + ); + } } } diff --git a/crates/workspace-server/src/runtime_subscription.rs b/crates/workspace-server/src/runtime_subscription.rs index 88a3a47e..5b5eb6bc 100644 --- a/crates/workspace-server/src/runtime_subscription.rs +++ b/crates/workspace-server/src/runtime_subscription.rs @@ -10,12 +10,11 @@ use protocol::subscription::{ SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, }; use tokio::sync::mpsc; -use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use worker_runtime::auth::{CapabilityTokenSigner, capability_claims}; +use tokio_tungstenite::{client_async_tls_with_config, connect_async}; -use crate::hosts::RemoteRuntimeConfig; +use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint}; const DOWNSTREAM_QUEUE_CAPACITY: usize = 256; const RECONNECT_DELAY: Duration = Duration::from_millis(100); @@ -918,10 +917,43 @@ async fn connect_runtime( .map_err(|error| format!("invalid Runtime authorization header: {error}"))?, ); } - connect_async(request) + if config.strict_public_egress { + let base_url = config.base_url.clone(); + let (_, addresses) = + tokio::task::spawn_blocking(move || resolve_strict_remote_runtime_endpoint(&base_url)) + .await + .map_err(|_| { + "Runtime subscription endpoint resolution task failed".to_string() + })??; + let stream = tokio::time::timeout(config.timeout, async move { + let mut last_error = None; + for address in addresses { + match tokio::net::TcpStream::connect(address).await { + Ok(stream) => return Ok(stream), + Err(error) => last_error = Some(error), + } + } + Err(last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "no validated Runtime address was available".to_string())) + }) .await + .map_err(|_| "Runtime subscription TCP connection timed out".to_string())??; + tokio::time::timeout( + config.timeout, + client_async_tls_with_config(request, stream, None, None), + ) + .await + .map_err(|_| "Runtime subscription TLS/WebSocket handshake timed out".to_string())? .map(|(socket, _)| socket) .map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}")) + } else { + tokio::time::timeout(config.timeout, connect_async(request)) + .await + .map_err(|_| "Runtime subscription connection timed out".to_string())? + .map(|(socket, _)| socket) + .map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}")) + } } fn runtime_endpoint(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); @@ -935,24 +967,15 @@ fn runtime_endpoint(base_url: &str) -> String { } fn runtime_token( config: &RemoteRuntimeConfig, - workspace_id: &str, + _workspace_id: &str, ) -> Result, String> { - let Some(auth) = config.auth.as_ref() else { - return Ok(config.bearer_token.clone()); - }; - let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key); - let claims = capability_claims( - &auth.server_id, - &config.runtime_id, - workspace_id, - vec!["workers:list".into()], - 300, - ) - .map_err(|error| error.to_string())?; - signer - .sign(&claims) - .map(Some) - .map_err(|error| error.to_string()) + if let Some(authorization) = config.workspace_authorization.as_ref() { + return authorization + .issue("GET", "/v1/protocol/ws", "workers:list", None, &[]) + .map(Some) + .map_err(|error| error.message); + } + Ok(config.bearer_token.clone()) } fn update_status(status: &RwLock, state: &State, connected: bool) { *status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus { diff --git a/crates/workspace-server/src/runtime_subscription_tests.rs b/crates/workspace-server/src/runtime_subscription_tests.rs index 74bc902a..7e3798e8 100644 --- a/crates/workspace-server/src/runtime_subscription_tests.rs +++ b/crates/workspace-server/src/runtime_subscription_tests.rs @@ -416,3 +416,19 @@ async fn embedded_runtime_uses_in_process_subscription_source() { )); server.abort(); } + +#[tokio::test] +async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() { + let config = RemoteRuntimeConfig::new( + "runtime-private", + "Private Runtime", + "https://169.254.169.254", + None, + ) + .with_strict_public_egress(true); + let error = match connect_runtime(&config, "workspace-a").await { + Err(error) => error, + Ok(_) => panic!("private endpoint unexpectedly produced a WebSocket"), + }; + assert!(error.contains("endpoint host is not public"), "{error}"); +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 8530cdc3..c5933742 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use std::net::IpAddr; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; @@ -60,6 +61,12 @@ use worker_runtime::http_server::{ }; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; +use worker_runtime::workspace_issuer::{ + WORKSPACE_VERIFICATION_ACK_PATH, WORKSPACE_VERIFICATION_CHALLENGE_PATH, + WORKSPACE_VERIFICATION_OPERATION, WorkspaceCapabilityClaims, + WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationChallenge, + verify_runtime_verification_response, workspace_request_body_digest, +}; use workspace_api::{ ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse, AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, @@ -75,16 +82,16 @@ use workspace_api::{ PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, - PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse, - RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, - RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest, - RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, - RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry, - RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse, - RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse, - WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, - WorkerLaunchWorkerSummary, + RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, + RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor, + RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest, + RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, + RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction, + RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse, + RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, + TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, + WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -95,9 +102,12 @@ use workspace_api::{ WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse, WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState, WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, - WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspaceRepositoryRecord, - WorkspaceResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary, - WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, + WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle, + WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeBindingState, + WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, + WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse, + WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, + WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; use crate::auth::{ @@ -124,7 +134,8 @@ use crate::hosts::{ WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest, - WorkerWorkspaceSummary, worker_spawn_create_fingerprint, workspace_worker_summary, + WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address, + worker_spawn_create_fingerprint, workspace_worker_summary, }; use crate::identity::WorkspaceIdentity; use crate::memory_backend::execute_memory_backend_operation_with_authority; @@ -159,7 +170,9 @@ use crate::store::{ RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, - WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, + WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode, + WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation, + WorkspaceRuntimeBindingState as StoredRuntimeBindingState, }; use crate::workdir_removal::{ WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation, @@ -167,6 +180,10 @@ use crate::workdir_removal::{ }; use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest}; use crate::workspace_deletion::WorkspaceDeletionStore; +use crate::workspace_signing_identity::{ + FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService, + WorkspaceSigningMaterialStore, workspace_signing_material_root, +}; use crate::{Error, Result}; use worker_runtime::catalog::{ ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation, @@ -585,6 +602,7 @@ pub struct WorkspaceApi { pub(crate) store: Arc, config_store: Arc, repository_secrets: Arc, + signing_identities: WorkspaceSigningIdentityService, config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, @@ -1027,6 +1045,7 @@ pub struct WorkspaceServerApi { template: Arc, store: Arc, catalog: WorkspaceCatalogService, + signing_materials: Arc, routers: Arc>>, apis: Arc>>, mutation_locks: Arc>>>>, @@ -1047,9 +1066,14 @@ async fn workspace_mutation_lock( impl WorkspaceServerApi { pub fn new(template: ServerConfig, store: Arc) -> Self { + let signing_materials: Arc = + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&template.database_path), + )); Self { template: Arc::new(template), - catalog: WorkspaceCatalogService::new(store.clone()), + catalog: WorkspaceCatalogService::new(store.clone(), signing_materials.clone()), + signing_materials, store, routers: Arc::new(AsyncMutex::new(HashMap::new())), apis: Arc::new(AsyncMutex::new(HashMap::new())), @@ -1283,6 +1307,8 @@ impl WorkspaceServerApi { None, ); } + WorkspaceSigningIdentityService::new(self.store.clone(), self.signing_materials.clone()) + .delete_material(&operation.workspace_id)?; let completed = self.store.finalize_workspace_deletion(operation_id)?; self.routers.lock().await.remove(&completed.workspace_id); if let Some(handle) = self @@ -2062,6 +2088,10 @@ impl WorkspaceApi { public_key: embedded_identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: config.workspace_created_at.clone(), updated_at: config.workspace_created_at.clone(), revoked_at: None, @@ -2093,22 +2123,12 @@ impl WorkspaceApi { )) })?; let runtime_binding_store = store.clone(); - let configured_runtime_endpoints = config - .remote_runtime_sources - .iter() - .filter_map(|source| { - (source.workspace_id.as_deref() == Some(config.workspace_id.as_str())) - .then(|| (source.runtime_id.clone(), source.base_url.clone())) - }) - .collect::>(); let expected_runtime_bindings = store .list_workspace_runtime_bindings(&config.workspace_id, false) .await? .into_iter() .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) - .filter(|binding| { - configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) - }) + .filter(|binding| binding.state != StoredRuntimeBindingState::Revoked) .map(|binding| { ( (binding.workspace_id.clone(), binding.runtime_id.clone()), @@ -2140,19 +2160,34 @@ impl WorkspaceApi { .unwrap_or(false) }) }); - let active_expectations = api - .runtime_binding_expectations - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for source in &api.config.remote_runtime_sources { - if !active_expectations - .contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone())) - { - api.runtime_subscription_broker - .unregister_runtime(&source.runtime_id); + { + let active_expectations = api + .runtime_binding_expectations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for source in &api.config.remote_runtime_sources { + if !active_expectations + .contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone())) + { + api.runtime_subscription_broker + .unregister_runtime(&source.runtime_id); + } } } - drop(active_expectations); + for binding in api + .store + .list_workspace_runtime_bindings(&api.config.workspace_id, false) + .await? + .into_iter() + .filter(|binding| { + binding.authentication_mode + == crate::store::WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + }) + .filter(|binding| binding.state != StoredRuntimeBindingState::Revoked) + { + let activate = binding.state == StoredRuntimeBindingState::Verified; + api.register_workspace_runtime_binding(binding, activate)?; + } Ok(api) } @@ -2204,14 +2239,45 @@ impl WorkspaceApi { RuntimeSubscriptionBroker::new(config.workspace_id.clone()); runtime_subscription_broker .register_embedded_runtime(embedded_runtime_id, embedded_subscription_runtime); - for remote_config in config.remote_runtime_sources.iter().cloned() { + let config_store = Arc::new(crate::SqliteWorkspaceStore::open( + config.database_path.clone(), + )?); + let repository_secrets = Arc::new(RepositorySecretService::open( + config_store.clone(), + &config.database_path, + )?); + let signing_materials: Arc = + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&config.database_path), + )); + let signing_identities = + WorkspaceSigningIdentityService::new(store.clone(), signing_materials); + signing_identities.get_validated(&config.workspace_id)?; + let backend_url = config + .backend_base_url + .clone() + .unwrap_or_else(|| "http://127.0.0.1:8787".to_string()); + for mut remote_config in config.remote_runtime_sources.iter().cloned() { + let current_binding = store + .get_workspace_runtime_binding(&config.workspace_id, &remote_config.runtime_id) + .await?; + if current_binding.as_ref().is_some_and(|binding| { + binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity + && binding.revoked_at.is_none() + }) { + let verified_binding = current_binding + .filter(|binding| binding.state == StoredRuntimeBindingState::Verified); + remote_config.workspace_authorization = Some(WorkspaceRuntimeAuthorization::new( + store.clone(), + signing_identities.clone(), + backend_url.clone(), + verified_binding, + )); + } let remote_runtime = RemoteWorkerRuntime::new( remote_config.clone(), config.workspace_id.clone(), - config - .backend_base_url - .clone() - .unwrap_or_else(|| "http://127.0.0.1:8787".to_string()), + backend_url.clone(), ) .map(|host| host.with_resource_broker(resource_broker.clone())) .map_err(|err| err.into_error())?; @@ -2221,13 +2287,6 @@ impl WorkspaceApi { let runtime = Arc::new(runtime); let companion = Arc::new(CompanionConsole::disabled()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); - let config_store = Arc::new(crate::SqliteWorkspaceStore::open( - config.database_path.clone(), - )?); - let repository_secrets = Arc::new(RepositorySecretService::open( - config_store.clone(), - &config.database_path, - )?); let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default() .with_provider(Arc::new( crate::profile_settings::ProfileConfigSchemaProvider, @@ -2244,6 +2303,7 @@ impl WorkspaceApi { let api = Self { config_store, repository_secrets, + signing_identities, config_schema_registry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache::default(), @@ -2286,6 +2346,37 @@ impl WorkspaceApi { self.config.workspace_id.as_str() } + fn register_workspace_runtime_binding( + &self, + binding: WorkspaceRuntimeBinding, + activate: bool, + ) -> Result<()> { + let backend_url = self + .config + .backend_base_url + .clone() + .unwrap_or_else(|| "http://127.0.0.1:8787".to_string()); + let mut remote_config = remote_runtime_config_from_binding(&binding) + .map_err(|diagnostic| Error::Store(diagnostic.message))?; + remote_config.workspace_authorization = Some(WorkspaceRuntimeAuthorization::new( + self.store.clone(), + self.signing_identities.clone(), + backend_url.clone(), + activate.then_some(binding), + )); + let remote_runtime = RemoteWorkerRuntime::new( + remote_config.clone(), + self.config.workspace_id.clone(), + backend_url, + ) + .map(|runtime| runtime.with_resource_broker(self.resource_broker.clone())) + .map_err(|error| error.into_error())?; + self.runtime.register_or_replace(remote_runtime); + self.runtime_subscription_broker + .register_remote_runtime(remote_config); + Ok(()) + } + pub fn runtime_subscription_broker(&self) -> &RuntimeSubscriptionBroker { &self.runtime_subscription_broker } @@ -2926,6 +3017,14 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/settings/workspace", get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), ) + .route( + "/api/w/{workspace_id}/settings/workspace/signing-identity", + get(scoped_get_workspace_signing_identity), + ) + .route( + "/api/w/{workspace_id}/settings/workspace/signing-identity/provision", + post(scoped_provision_workspace_signing_identity), + ) .route( "/api/w/{workspace_id}/settings/memory", get(scoped_get_workspace_memory_settings) @@ -3276,9 +3375,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", - get(scoped_reveal_runtime_trust_key) - .put(scoped_put_runtime_trust_key) - .delete(scoped_revoke_runtime_trust_key), + get(scoped_reveal_runtime_trust_key).delete(scoped_revoke_runtime_trust_key), ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", @@ -4087,6 +4184,107 @@ async fn scoped_update_workspace_settings( })) } +async fn scoped_get_workspace_signing_identity( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Workspace public identity access", + ) + .await?; + let identity = api.signing_identities.get_validated(&path.workspace_id)?; + Ok(Json(project_workspace_signing_identity(&api, identity)?)) +} + +async fn scoped_provision_workspace_signing_identity( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Workspace signing identity provisioning", + ) + .await?; + let identity = api + .signing_identities + .provision_existing(&path.workspace_id, &actor.account_id)?; + Ok(Json(project_workspace_signing_identity(&api, identity)?)) +} + +fn project_workspace_signing_identity( + api: &WorkspaceApi, + identity: crate::store::WorkspaceSigningIdentityRecord, +) -> Result { + let state = match identity.state.as_str() { + "pending_provisioning" => WorkspaceSigningIdentityState::PendingProvisioning, + "active" => WorkspaceSigningIdentityState::Active, + _ => { + return Err(crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_state_invalid", + "Workspace signing identity state is invalid", + )); + } + }; + let public_bundle = if state == WorkspaceSigningIdentityState::Active { + let public_key = identity.public_key.clone().ok_or_else(|| { + crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_metadata_corrupt", + "Active Workspace signing identity has no public key", + ) + })?; + let public_key_fingerprint = identity.public_key_fingerprint.clone().ok_or_else(|| { + crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_metadata_corrupt", + "Active Workspace signing identity has no public key fingerprint", + ) + })?; + let backend_url = api + .config + .backend_base_url + .as_deref() + .ok_or_else(|| { + crate::workspace_signing_identity::identity_error( + "workspace_signing_identity_backend_url_unavailable", + "Backend public URL is unavailable for the Workspace identity bundle", + ) + })? + .trim_end_matches('/') + .to_string(); + Some(WorkspacePublicIdentityBundle { + workspace_id: identity.workspace_id.clone(), + backend_url, + key_id: identity.key_id.clone(), + algorithm: identity.algorithm.clone(), + public_key, + public_key_fingerprint, + revision: identity.revision, + }) + } else { + None + }; + Ok(WorkspaceSigningIdentityResponse { + identity: WorkspaceSigningIdentityPublic { + workspace_id: identity.workspace_id, + key_id: identity.key_id, + algorithm: identity.algorithm, + public_key: identity.public_key, + public_key_fingerprint: identity.public_key_fingerprint, + revision: identity.revision, + state, + created_at: identity.created_at, + provisioned_at: identity.provisioned_at, + }, + public_bundle, + }) +} + #[derive(Debug, Deserialize)] struct WorkspaceConfigRevisionPath { workspace_id: String, @@ -11595,10 +11793,11 @@ fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError { async fn scoped_create_remote_runtime( State(api): State, AxumPath(path): AxumPath, + Extension(actor): Extension, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { validate_workspace_scope(&api, &path.workspace_id)?; - create_remote_runtime(State(api), Json(request)).await + create_remote_runtime(State(api), Extension(actor), Json(request)).await } async fn scoped_get_runtime_detail( @@ -11627,7 +11826,7 @@ async fn scoped_reveal_runtime_trust_key( 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", + "the embedded Runtime trust key is managed by the embedded Runtime authority", )); } let binding = api @@ -11642,106 +11841,6 @@ async fn scoped_reveal_runtime_trust_key( })) } -async fn scoped_put_runtime_trust_key( - State(api): State, - AxumPath(path): AxumPath, - Extension(actor): Extension, - Json(request): Json, -) -> std::result::Result { - validate_workspace_scope(&api, &path.workspace_id)?; - require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?; - let actor_account_id = actor.account_id.clone(); - 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", - )); - } - if request.expected_revision == Some(0) { - return Err(settings_bad_request( - "invalid_runtime_binding_revision", - "expected_revision must be greater than zero when provided", - )); - } - if request.public_key.len() > 16 * 1024 { - return Err(settings_bad_request( - "runtime_public_key_too_large", - "public_key must be at most 16384 bytes", - )); - } - let existing = api - .store - .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) - .await?; - let source = api - .config - .remote_runtime_sources - .iter() - .find(|source| { - source.runtime_id == path.runtime_id - && source.workspace_id.as_deref() == Some(path.workspace_id.as_str()) - }) - .cloned(); - if let (Some(binding), Some(source)) = (&existing, &source) - && binding.base_url != source.base_url - { - return Err(settings_bad_request( - "runtime_endpoint_mismatch", - "the persisted Runtime endpoint no longer matches Server Runtime configuration; reconcile the endpoint before changing trust", - )); - } - let (display_name, base_url) = if let Some(binding) = &existing { - (binding.display_name.clone(), binding.base_url.clone()) - } else if let Some(source) = &source { - (source.display_name.clone(), source.base_url.clone()) - } else { - return Err(Error::UnknownRuntime(path.runtime_id.clone()).into()); - }; - let now = Utc::now().to_rfc3339(); - let record = WorkspaceRuntimeBinding { - workspace_id: path.workspace_id.clone(), - runtime_id: path.runtime_id.clone(), - display_name, - base_url, - public_key: request.public_key, - public_key_fingerprint: String::new(), - binding_revision: 1, - created_at: existing - .as_ref() - .map_or_else(|| now.clone(), |binding| binding.created_at.clone()), - updated_at: now, - revoked_at: None, - }; - let mutation = api - .store - .put_workspace_runtime_binding_key(record, request.expected_revision, &actor_account_id) - .await; - let (_, binding) = match mutation { - Ok(result) => result, - Err(error) => { - if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await { - return Ok(response); - } - return Err(error.into()); - } - }; - api.runtime_binding_expectations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - (path.workspace_id.clone(), path.runtime_id.clone()), - binding, - ); - if let Some(source) = source { - api.runtime_subscription_broker - .register_remote_runtime(source); - } - Ok( - Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?) - .into_response(), - ) -} - async fn scoped_revoke_runtime_trust_key( State(api): State, AxumPath(path): AxumPath, @@ -11754,7 +11853,7 @@ async fn scoped_revoke_runtime_trust_key( 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", + "the embedded Runtime trust key is managed by the embedded Runtime authority", )); } if request.expected_revision == 0 { @@ -11850,8 +11949,40 @@ async fn scoped_delete_remote_runtime( async fn scoped_test_runtime_connection( State(api): State, AxumPath(path): AxumPath, + Extension(actor): Extension, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Runtime connection verification", + ) + .await?; + let binding = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await? + .ok_or_else(|| Error::UnknownRuntime(path.runtime_id.clone()))?; + if binding.state == StoredRuntimeBindingState::Revoked || binding.revoked_at.is_some() { + return Err(Error::RuntimeBindingConflict(format!( + "Runtime `{}` binding is revoked", + path.runtime_id + )) + .into()); + } + if binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity { + validate_runtime_connection_request(&CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: binding.runtime_id.clone(), + public_key: binding.public_key.clone(), + }, + display_name: Some(binding.display_name.clone()), + endpoint: binding.base_url.clone(), + expected_revision: Some(binding.binding_revision), + }) + .await?; + } test_runtime_connection(State(api), AxumPath(path.runtime_id)).await } @@ -13315,31 +13446,110 @@ async fn list_workers( } async fn create_remote_runtime( - State(_api): State, + State(api): State, + Extension(actor): Extension, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { - validate_runtime_connection_request(&request)?; - let id = request.runtime_id.trim().to_string(); - if id == EMBEDDED_WORKER_RUNTIME_ID { + require_workspace_owner( + &api, + &api.config.workspace_id, + &actor, + "manage Workspace Runtimes", + ) + .await?; + let endpoint = validate_runtime_connection_request(&request).await?; + let runtime_id = request.public_bundle.identity_id.trim().to_string(); + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { return Err(settings_bad_request( "embedded_runtime_not_config_managed", "the embedded Runtime is built in and cannot be managed as a remote Runtime", )); } - if request - .token_ref - .as_ref() - .is_some_and(|value| !value.trim().is_empty()) - { + let identity = api + .signing_identities + .get_validated(&api.config.workspace_id)?; + if identity.state != "active" { return Err(settings_bad_request( - "remote_runtime_token_ref_unsupported", - "remote Runtime token_ref persistence is not supported", + "workspace_signing_identity_unavailable", + "an active Workspace signing identity is required before registering a Runtime", )); } - Err(settings_bad_request( - "runtime_public_key_required", - "remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered", - )) + let existing = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) + .await?; + if existing + .as_ref() + .is_some_and(|binding| binding.state == StoredRuntimeBindingState::Verified) + { + return Err(Error::RuntimeBindingConflict( + "a verified Runtime binding must be revoked before it can be replaced as configured" + .to_string(), + ) + .into()); + } + match api + .runtime + .unregister_if_idle(&runtime_id, api.config.max_records.min(200)) + .map_err(|err| err.into_error())? + { + RuntimeRegistryUnregisterResult::Removed | RuntimeRegistryUnregisterResult::NotFound => {} + RuntimeRegistryUnregisterResult::BlockedByWorkers { worker_count, .. } => { + return Err(Error::RuntimeBindingConflict(format!( + "Runtime `{runtime_id}` still has {worker_count} active worker(s) and cannot become a configured-only binding" + )) + .into()); + } + } + let now = Utc::now().to_rfc3339(); + let record = WorkspaceRuntimeBinding { + workspace_id: api.config.workspace_id.clone(), + runtime_id: runtime_id.clone(), + display_name: request + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&runtime_id) + .to_string(), + base_url: endpoint.to_string().trim_end_matches('/').to_string(), + public_key: request.public_bundle.public_key.trim().to_string(), + public_key_fingerprint: String::new(), + binding_revision: request.expected_revision.unwrap_or(0), + state: StoredRuntimeBindingState::Configured, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some(identity.key_id.clone()), + workspace_key_generation: Some(identity.revision), + created_at: now.clone(), + updated_at: now, + revoked_at: None, + }; + let (mutation, binding) = api + .store + .put_workspace_runtime_binding_key(record, request.expected_revision, &actor.account_id) + .await?; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + (api.config.workspace_id.clone(), runtime_id.clone()), + binding.clone(), + ); + api.runtime_subscription_broker + .unregister_runtime(&runtime_id); + api.register_workspace_runtime_binding(binding, false)?; + let resource = workspace_runtime_resources_response(&api, &api.config.workspace_id) + .await? + .items + .into_iter() + .find(|resource| resource.runtime.runtime_id == runtime_id) + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + let status = if mutation == WorkspaceRuntimeBindingMutation::Created { + StatusCode::CREATED + } else { + StatusCode::OK + }; + Ok((status, Json(resource))) } async fn delete_remote_runtime( @@ -13394,6 +13604,213 @@ async fn delete_remote_runtime( Ok(StatusCode::NO_CONTENT) } +async fn perform_workspace_runtime_verification( + api: &WorkspaceApi, + runtime: Arc, + binding: &WorkspaceRuntimeBinding, +) -> std::result::Result { + if binding.authentication_mode != StoredRuntimeAuthenticationMode::WorkspaceIdentity { + return Ok(binding.clone()); + } + if binding.state == crate::store::WorkspaceRuntimeBindingState::Revoked + || binding.revoked_at.is_some() + { + return Err("Runtime binding is revoked".to_string()); + } + let backend_url = api.config.backend_base_url.as_deref().ok_or_else(|| { + "Workspace identity verification requires configured backend_base_url".to_string() + })?; + let identity = api + .signing_identities + .get_validated(&binding.workspace_id) + .map_err(|error| error.to_string())?; + if identity.state != "active" { + return Err("Workspace signing identity is not active".to_string()); + } + let workspace_key_id = binding + .workspace_key_id + .as_deref() + .ok_or_else(|| "Runtime binding is missing the Workspace key identity".to_string())?; + let workspace_trust_generation = binding + .workspace_key_generation + .ok_or_else(|| "Runtime binding is missing the Workspace trust generation".to_string())?; + if identity.key_id != workspace_key_id || identity.revision != workspace_trust_generation { + return Err( + "Runtime binding no longer matches the active Workspace or Runtime identity" + .to_string(), + ); + } + + let now = Utc::now(); + let expires_at = (now + Duration::seconds(60)).timestamp(); + let challenge = WorkspaceRuntimeVerificationChallenge { + challenge_id: Uuid::now_v7().to_string(), + workspace_id: binding.workspace_id.clone(), + runtime_id: binding.runtime_id.clone(), + binding_revision: binding.binding_revision, + workspace_key_id: workspace_key_id.to_string(), + workspace_identity_revision: identity.revision, + workspace_trust_generation, + runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(), + runtime_identity_revision: 1, + workspace_nonce: Uuid::now_v7().to_string(), + expires_at, + }; + let checked_at = now.to_rfc3339_opts(SecondsFormat::Millis, true); + let pending = crate::store::WorkspaceRuntimeVerificationEvidence { + workspace_id: binding.workspace_id.clone(), + runtime_id: binding.runtime_id.clone(), + binding_revision: binding.binding_revision, + workspace_key_id: workspace_key_id.to_string(), + workspace_identity_revision: identity.revision, + workspace_trust_generation, + runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(), + runtime_identity_revision: 1, + challenge_id: challenge.challenge_id.clone(), + state: "pending".to_string(), + last_outcome: "challenge_issued".to_string(), + verified_at: None, + checked_at: checked_at.clone(), + }; + api.store + .record_workspace_runtime_verification_attempt(&pending) + .await + .map_err(|error| error.to_string())?; + + let result = async { + let challenge_body = serde_json::to_vec(&challenge).map_err(|error| error.to_string())?; + let challenge_claims = WorkspaceCapabilityClaims { + issuer: backend_url.to_string(), + issuer_workspace_id: binding.workspace_id.clone(), + issuer_key_id: identity.key_id.clone(), + issuer_identity_revision: identity.revision, + trust_generation: workspace_trust_generation, + binding_revision: binding.binding_revision, + runtime_id: binding.runtime_id.clone(), + worker_id: None, + operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), + method: "POST".to_string(), + path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.to_string(), + body_digest: workspace_request_body_digest(&challenge_body), + iat: now.timestamp(), + exp: expires_at, + jti: Uuid::now_v7().to_string(), + }; + let challenge_token = api + .signing_identities + .issue_workspace_capability(&binding.workspace_id, &challenge_claims) + .map_err(|error| error.to_string())?; + let challenge_runtime = runtime.clone(); + let challenge_runtime_id = binding.runtime_id.clone(); + let challenge_request = challenge.clone(); + let response = tokio::task::spawn_blocking(move || { + challenge_runtime.send_workspace_verification_challenge( + &challenge_runtime_id, + &challenge_request, + &challenge_token, + ) + }) + .await + .map_err(|_| "Runtime verification challenge task failed".to_string())? + .map_err(|failure| failure.diagnostic.message)?; + verify_runtime_verification_response( + &response, + &challenge, + &binding.public_key, + Utc::now().timestamp(), + ) + .map_err(|error| error.to_string())?; + + let response_bytes = serde_json::to_vec(&response).map_err(|error| error.to_string())?; + let acknowledgement = WorkspaceRuntimeVerificationAcknowledgement { + challenge_id: response.challenge_id.clone(), + workspace_id: response.workspace_id.clone(), + runtime_id: response.runtime_id.clone(), + binding_revision: response.binding_revision, + workspace_key_id: response.workspace_key_id.clone(), + workspace_identity_revision: response.workspace_identity_revision, + workspace_trust_generation: response.workspace_trust_generation, + runtime_public_key_fingerprint: response.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: response.runtime_identity_revision, + workspace_nonce: response.workspace_nonce.clone(), + runtime_nonce: response.runtime_nonce.clone(), + response_digest: workspace_request_body_digest(&response_bytes), + response: response.clone(), + expires_at: response.expires_at, + }; + let acknowledgement_body = + serde_json::to_vec(&acknowledgement).map_err(|error| error.to_string())?; + let acknowledgement_claims = WorkspaceCapabilityClaims { + issuer: backend_url.to_string(), + issuer_workspace_id: binding.workspace_id.clone(), + issuer_key_id: identity.key_id.clone(), + issuer_identity_revision: identity.revision, + trust_generation: workspace_trust_generation, + binding_revision: binding.binding_revision, + runtime_id: binding.runtime_id.clone(), + worker_id: None, + operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), + method: "POST".to_string(), + path_and_query: WORKSPACE_VERIFICATION_ACK_PATH.to_string(), + body_digest: workspace_request_body_digest(&acknowledgement_body), + iat: Utc::now().timestamp(), + exp: expires_at, + jti: Uuid::now_v7().to_string(), + }; + let acknowledgement_token = api + .signing_identities + .issue_workspace_capability(&binding.workspace_id, &acknowledgement_claims) + .map_err(|error| error.to_string())?; + let acknowledgement_runtime = runtime; + let acknowledgement_runtime_id = binding.runtime_id.clone(); + let acknowledgement_request = acknowledgement.clone(); + let receipt = tokio::task::spawn_blocking(move || { + acknowledgement_runtime.send_workspace_verification_acknowledgement( + &acknowledgement_runtime_id, + &acknowledgement_request, + &acknowledgement_token, + ) + }) + .await + .map_err(|_| "Runtime verification acknowledgement task failed".to_string())? + .map_err(|failure| failure.diagnostic.message)?; + if receipt.challenge_id != challenge.challenge_id + || receipt.workspace_id != binding.workspace_id + || receipt.runtime_id != binding.runtime_id + || receipt.binding_revision != binding.binding_revision + { + return Err("Runtime verification acknowledgement receipt mismatched".to_string()); + } + + let verified_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let verified = crate::store::WorkspaceRuntimeVerificationEvidence { + state: "verified".to_string(), + last_outcome: "verified".to_string(), + verified_at: Some(verified_at.clone()), + checked_at: verified_at, + ..pending.clone() + }; + api.store + .complete_workspace_runtime_verification(&verified) + .await + .map_err(|error| error.to_string()) + } + .await; + if result.is_err() { + let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let _ = api + .store + .record_workspace_runtime_verification_outcome_if_current( + &pending, + "failed", + "verification_failed", + &checked_at, + ) + .await; + } + result +} + async fn test_runtime_connection( State(api): State, AxumPath(runtime_id): AxumPath, @@ -13406,12 +13823,51 @@ async fn test_runtime_connection( } .into()); } - api.store + let binding = api + .store .get_workspace_runtime_binding(api.workspace_id(), &runtime_id) .await? .filter(|binding| binding.revoked_at.is_none()) .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + if binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity { + match perform_workspace_runtime_verification(&api, api.runtime.clone(), &binding).await { + Ok(verified_binding) => { + api.register_workspace_runtime_binding(verified_binding.clone(), true)?; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + ( + verified_binding.workspace_id.clone(), + verified_binding.runtime_id.clone(), + ), + verified_binding.clone(), + ); + api.runtime + .activate_workspace_authorization(&runtime_id, verified_binding.clone()) + .map_err(|error| Error::Store(format!("{error:?}")))?; + } + Err(message) => { + let mut result = runtime_connection_test_failure( + api.workspace_id(), + &runtime_id, + Utc::now().to_rfc3339(), + RuntimeConnectionTestFailureKind::Authentication, + None, + RuntimeDiagnostic::new( + "runtime_workspace_verification_failed", + "error", + message, + ), + ); + result.binding_revision = binding.binding_revision; + result.connection_state = RuntimeConnectionDisplayState::Unavailable; + return Ok(Json(result)); + } + } + } + let checked_at = Utc::now().to_rfc3339(); let runtime = api.runtime.clone(); let ping_runtime_id = runtime_id.clone(); @@ -13423,12 +13879,45 @@ async fn test_runtime_connection( message: "Runtime connection test could not be completed".to_string(), })?; - Ok(Json(runtime_connection_test_response( - api.workspace_id(), - &runtime_id, - checked_at, - ping, - ))) + if ping.is_err() + && binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity + && let Some(evidence) = api + .store + .get_workspace_runtime_verification(api.workspace_id(), &runtime_id) + .await? + { + let checked_at = Utc::now().to_rfc3339(); + api.store + .record_workspace_runtime_verification_outcome_if_current( + &evidence, + "failed", + "connectivity_failed", + &checked_at, + ) + .await?; + } + let current_binding = api + .store + .get_workspace_runtime_binding(api.workspace_id(), &runtime_id) + .await? + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + let verification = api + .store + .get_workspace_runtime_verification(api.workspace_id(), &runtime_id) + .await?; + let summary = runtime_binding_summary(¤t_binding, verification.as_ref()); + let mut result = + runtime_connection_test_response(api.workspace_id(), &runtime_id, checked_at, ping); + result.binding_revision = current_binding.binding_revision; + result.connection_state = if result.status == RuntimeConnectionTestStatus::Compatible { + summary.connection_state + } else if summary.connection_state == RuntimeConnectionDisplayState::Revoked { + RuntimeConnectionDisplayState::Revoked + } else { + RuntimeConnectionDisplayState::Unavailable + }; + result.verification = summary.verification; + Ok(Json(result)) } async fn get_worker_launch_options( @@ -15527,6 +16016,16 @@ async fn workspace_runtime_resources_response( .store .list_workspace_runtime_bindings(workspace_id, true) .await?; + let mut verifications = HashMap::new(); + for binding in &bindings { + if let Some(verification) = api + .store + .get_workspace_runtime_verification(workspace_id, &binding.runtime_id) + .await? + { + verifications.insert(binding.runtime_id.clone(), verification); + } + } let mut items = runtimes .items .into_iter() @@ -15535,8 +16034,12 @@ async fn workspace_runtime_resources_response( .iter() .find(|binding| binding.runtime_id == runtime.runtime_id); let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; + let mut runtime: workspace_api::RuntimeSummary = runtime.into(); + if binding.is_some_and(|binding| binding.state != StoredRuntimeBindingState::Verified) { + runtime.worker_creation_available = false; + } WorkspaceRuntimeResource { - runtime: runtime.into(), + runtime, management: RuntimeManagementSummary { built_in, config_managed: binding.is_some(), @@ -15544,6 +16047,9 @@ async fn workspace_runtime_resources_response( endpoint_configured: binding .is_some_and(|binding| !binding.base_url.trim().is_empty()), token_ref_configured: false, + binding: binding.map(|binding| { + runtime_binding_summary(binding, verifications.get(&binding.runtime_id)) + }), }, } }) @@ -15589,6 +16095,10 @@ async fn workspace_runtime_resources_response( removable: true, endpoint_configured: !binding.base_url.trim().is_empty(), token_ref_configured: false, + binding: Some(runtime_binding_summary( + binding, + verifications.get(&binding.runtime_id), + )), }, }); } @@ -15602,6 +16112,68 @@ async fn workspace_runtime_resources_response( }) } +fn runtime_binding_summary( + binding: &WorkspaceRuntimeBinding, + verification: Option<&crate::store::WorkspaceRuntimeVerificationEvidence>, +) -> WorkspaceRuntimeBindingSummary { + let valid_verification = verification.filter(|verification| { + verification.state == "verified" + && verification.binding_revision == binding.binding_revision + && verification.runtime_public_key_fingerprint == binding.public_key_fingerprint + && verification.workspace_key_id + == binding.workspace_key_id.as_deref().unwrap_or_default() + }); + let connection_state = match binding.state { + StoredRuntimeBindingState::Revoked => RuntimeConnectionDisplayState::Revoked, + _ if verification.is_some_and(|verification| verification.last_outcome != "verified") => { + RuntimeConnectionDisplayState::Unavailable + } + StoredRuntimeBindingState::Verified + if binding.authentication_mode + == StoredRuntimeAuthenticationMode::LegacyServerIssuer + || valid_verification.is_some() => + { + RuntimeConnectionDisplayState::Verified + } + _ => RuntimeConnectionDisplayState::Configured, + }; + WorkspaceRuntimeBindingSummary { + state: match binding.state { + StoredRuntimeBindingState::Configured => WorkspaceRuntimeBindingState::Configured, + StoredRuntimeBindingState::Verified => WorkspaceRuntimeBindingState::Verified, + StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked, + }, + connection_state, + revision: binding.binding_revision, + workspace_key_id: binding.workspace_key_id.clone(), + workspace_key_generation: binding.workspace_key_generation, + verification: verification.and_then(runtime_verification_summary), + } +} + +fn runtime_verification_summary( + verification: &crate::store::WorkspaceRuntimeVerificationEvidence, +) -> Option { + let last_outcome = match verification.last_outcome.as_str() { + "verified" => workspace_api::RuntimeVerificationOutcome::Verified, + "challenge_issued" => workspace_api::RuntimeVerificationOutcome::ChallengeIssued, + "verification_failed" => workspace_api::RuntimeVerificationOutcome::VerificationFailed, + "connectivity_failed" => workspace_api::RuntimeVerificationOutcome::ConnectivityFailed, + _ => return None, + }; + Some(workspace_api::RuntimeVerificationEvidenceSummary { + verified_at: verification.verified_at.clone(), + last_checked_at: verification.checked_at.clone(), + last_outcome, + binding_revision: verification.binding_revision, + workspace_key_id: verification.workspace_key_id.clone(), + workspace_identity_revision: verification.workspace_identity_revision, + workspace_trust_generation: verification.workspace_trust_generation, + runtime_public_key_fingerprint: verification.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: verification.runtime_identity_revision, + }) +} + async fn workspace_runtime_detail( api: &WorkspaceApi, workspace_id: &str, @@ -15643,6 +16215,7 @@ async fn workspace_runtime_detail( removable: false, endpoint_configured: !binding.base_url.trim().is_empty(), token_ref_configured: false, + binding: Some(runtime_binding_summary(&binding, None)), }, }); } @@ -15726,27 +16299,86 @@ fn project_runtime_trust_audit( }) } -fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> { - validate_public_runtime_id(request.runtime_id.trim())?; - let endpoint = request.endpoint.trim(); - if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) +async fn validate_runtime_connection_request( + request: &CreateRemoteRuntimeRequest, +) -> ApiResult { + validate_public_runtime_id(request.public_bundle.identity_id.trim())?; + if request.public_bundle.public_key.trim().is_empty() { + return Err(settings_bad_request( + "runtime_public_key_required", + "Runtime public bundle must contain a public key", + )); + } + let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| { + settings_bad_request( + "invalid_remote_runtime_endpoint", + "endpoint must be an absolute https URL", + ) + })?; + if endpoint.scheme() != "https" + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() { return Err(settings_bad_request( - "invalid_remote_runtime_endpoint", - "endpoint must be an absolute http or https URL", + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint must be an https origin without credentials, query, or fragment", + )); + } + let host = endpoint.host_str().expect("checked above"); + if host.eq_ignore_ascii_case("localhost") + || host.ends_with(".localhost") + || host + .parse::() + .is_ok_and(is_disallowed_remote_runtime_address) + { + return Err(settings_bad_request( + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", + )); + } + let port = endpoint.port_or_known_default().unwrap_or(443); + let addresses = tokio::time::timeout( + std::time::Duration::from_secs(3), + tokio::net::lookup_host((host, port)), + ) + .await + .map_err(|_| { + settings_bad_request( + "remote_runtime_endpoint_dns_timeout", + "Runtime endpoint DNS resolution timed out", + ) + })? + .map_err(|_| { + settings_bad_request( + "remote_runtime_endpoint_dns_failed", + "Runtime endpoint DNS resolution failed", + ) + })? + .collect::>(); + if addresses.is_empty() + || addresses + .iter() + .any(|address| is_disallowed_remote_runtime_address(address.ip())) + { + return Err(settings_bad_request( + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", )); } if request .display_name .as_deref() - .is_some_and(|value| value.chars().any(char::is_control)) + .is_some_and(|value| value.is_empty() || value.chars().any(char::is_control)) { return Err(settings_bad_request( "invalid_remote_runtime_display_name", - "display_name cannot contain control characters", + "display_name must be non-empty when supplied and cannot contain control characters", )); } - Ok(()) + Ok(endpoint) } fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { @@ -15769,7 +16401,6 @@ fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { Ok(()) } -#[cfg(test)] fn remote_runtime_config_from_binding( binding: &crate::store::WorkspaceRuntimeBinding, ) -> std::result::Result { @@ -15779,7 +16410,10 @@ fn remote_runtime_config_from_binding( binding.base_url.clone(), None, ) - .with_workspace_id(binding.workspace_id.clone()); + .with_workspace_id(binding.workspace_id.clone()) + .with_strict_public_egress( + binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity, + ); Ok(remote) } @@ -15830,6 +16464,9 @@ fn runtime_connection_test_response( Ok(ping) => RuntimeConnectionTestResponse { workspace_id: workspace_id.to_string(), runtime_id: runtime_id.to_string(), + binding_revision: 0, + connection_state: RuntimeConnectionDisplayState::Configured, + verification: None, checked_at, status: RuntimeConnectionTestStatus::Compatible, failure_kind: None, @@ -15879,6 +16516,9 @@ fn runtime_connection_test_failure( RuntimeConnectionTestResponse { workspace_id: workspace_id.to_string(), runtime_id: runtime_id.to_string(), + binding_revision: 0, + connection_state: RuntimeConnectionDisplayState::Unavailable, + verification: None, checked_at, status: RuntimeConnectionTestStatus::Failed, failure_kind: Some(failure_kind), @@ -17222,6 +17862,11 @@ impl From for ApiError { severity: DiagnosticSeverity::Error, message: sanitize_backend_error(message), }], + Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic { + code: code.clone(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }], Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { code: match ticket_error { ticket::TicketError::NotFound(_) => "ticket_not_found", @@ -17378,6 +18023,7 @@ impl IntoResponse for ApiError { { StatusCode::SERVICE_UNAVAILABLE } + Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -17440,8 +18086,8 @@ mod tests { use worker_runtime::working_directory::WorkingDirectoryMaterializer; use crate::hosts::{ - RemoteRuntimeAuthConfig, TicketWorkerRole, WorkerInputKind, WorkerOperationState, - WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, + TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement, + WorkerSpawnIntent, }; use crate::store::{ AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord, @@ -17897,16 +18543,15 @@ mod tests { identity: &worker_runtime::auth::RuntimeIdentityMaterial, runtime_id: &str, ) { + api.config.backend_base_url = Some("server-test".to_owned()); api.config.remote_runtime_sources.push(RemoteRuntimeConfig { runtime_id: runtime_id.to_owned(), workspace_id: Some(api.workspace_id().to_owned()), display_name: runtime_id.to_owned(), base_url: "https://runtime.test".to_owned(), bearer_token: None, - auth: Some(RemoteRuntimeAuthConfig { - server_id: "server-test".to_owned(), - server_private_key: "unused".to_owned(), - }), + workspace_authorization: None, + strict_public_egress: false, cached_worker_creation_available: true, cached_os: "test".to_owned(), cached_arch: "test".to_owned(), @@ -17924,6 +18569,10 @@ mod tests { public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("WK-test".to_owned()), + workspace_key_generation: Some(1), created_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, @@ -19592,21 +20241,279 @@ mod tests { assert!(!serialized.contains("materialized_path")); } - #[test] - fn runtime_connection_request_validation_bounds_browser_input() { - let ok = CreateRemoteRuntimeRequest { - runtime_id: "team-runtime_1".to_string(), - display_name: Some("Team Runtime".to_string()), - endpoint: "https://runtime.example".to_string(), - token_ref: None, + #[tokio::test] + async fn verified_workspace_runtime_binding_is_restored_into_live_registry() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let api = test_api(&root).await; + let actor = test_owner_actor(); + let identity = api + .signing_identities + .provision_existing(&api.config.workspace_id, &actor.account_id) + .unwrap(); + let runtime_identity = RuntimeIdentityMaterial::generate("restored-runtime").unwrap(); + api.store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: api.config.workspace_id.clone(), + runtime_id: "restored-runtime".to_string(), + display_name: "Restored Runtime".to_string(), + base_url: "https://8.8.8.8".to_string(), + public_key: runtime_identity.public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + state: StoredRuntimeBindingState::Configured, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some(identity.key_id.clone()), + workspace_key_generation: Some(identity.revision), + created_at: "1".to_string(), + updated_at: "1".to_string(), + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let configured = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, "restored-runtime") + .await + .unwrap() + .unwrap(); + let evidence = crate::store::WorkspaceRuntimeVerificationEvidence { + workspace_id: configured.workspace_id.clone(), + runtime_id: configured.runtime_id.clone(), + binding_revision: configured.binding_revision, + workspace_key_id: identity.key_id, + workspace_identity_revision: identity.revision, + workspace_trust_generation: identity.revision, + runtime_public_key_fingerprint: configured.public_key_fingerprint.clone(), + runtime_identity_revision: 1, + challenge_id: "restart-challenge".to_string(), + state: "verified".to_string(), + last_outcome: "verified".to_string(), + verified_at: Some("2".to_string()), + checked_at: "2".to_string(), }; - assert!(validate_runtime_connection_request(&ok).is_ok()); + api.store + .record_workspace_runtime_verification_attempt(&evidence) + .await + .unwrap(); + api.store + .complete_workspace_runtime_verification(&evidence) + .await + .unwrap(); + drop(api); + + let restored_config = test_server_config(&root); + let restored_store = + SqliteWorkspaceStore::open(restored_config.database_path.clone()).unwrap(); + let restored = WorkspaceApi::new(restored_config, Arc::new(restored_store)) + .await + .unwrap(); + assert!( + restored + .runtime + .list_runtimes(100) + .items + .iter() + .any(|runtime| runtime.runtime_id == "restored-runtime"), + "verified persisted Runtime binding must return to the live registry" + ); + assert!( + restored + .runtime_binding_expectations + .read() + .unwrap() + .contains_key(&( + restored.config.workspace_id.clone(), + "restored-runtime".to_string(), + )), + "restored Runtime must retain its revision-fenced binding expectation" + ); + } + + #[tokio::test] + async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let actor = test_owner_actor(); + api.signing_identities + .provision_existing(&api.config.workspace_id, &actor.account_id) + .unwrap(); + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + RemoteRuntimeConfig::new( + "configured-runtime", + "Stale Runtime", + "https://8.8.8.8", + None, + ), + api.config.workspace_id.clone(), + "http://127.0.0.1:1".to_string(), + ) + .unwrap(), + ); + let runtime_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap(); + let request = CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "configured-runtime".to_string(), + public_key: runtime_identity.public_key, + }, + display_name: Some("Configured Runtime".to_string()), + endpoint: "https://8.8.8.8".to_string(), + expected_revision: None, + }; + let mut non_owner = actor.clone(); + non_owner.user_id = "other-user".to_string(); + non_owner.account_id = "other-account".to_string(); + non_owner.handle = "other".to_string(); + let denied = create_remote_runtime( + State(api.clone()), + Extension(non_owner), + Json(request.clone()), + ) + .await + .unwrap_err(); + assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); + + let (status, Json(created)) = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(request.clone()), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + let binding = created.management.binding.as_ref().unwrap(); + assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured); + assert_eq!(binding.revision, 1); + assert!(binding.workspace_key_id.is_some()); + assert_eq!(binding.workspace_key_generation, Some(1)); + assert!(!created.runtime.worker_creation_available); + assert!( + api.runtime + .list_runtimes(100) + .items + .iter() + .any(|runtime| runtime.runtime_id == "configured-runtime"), + "configured binding must remain registered so verification can reach it" + ); + assert!( + api.runtime_binding_expectations + .read() + .unwrap() + .contains_key(&( + api.config.workspace_id.clone(), + "configured-runtime".to_string(), + )), + "configured binding must remain fenced by its current binding revision" + ); + let Json(configured_test) = scoped_test_runtime_connection( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: api.config.workspace_id.clone(), + runtime_id: "configured-runtime".to_string(), + }), + Extension(actor.clone()), + ) + .await + .unwrap(); + assert_eq!(configured_test.status, RuntimeConnectionTestStatus::Failed); + assert_eq!( + configured_test.connection_state, + RuntimeConnectionDisplayState::Unavailable + ); + + let (status, Json(replayed)) = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(request.clone()), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK); + assert_eq!(replayed.management.binding.unwrap().revision, 1); + + let mut mismatched = request.clone(); + mismatched.display_name = Some("Different Runtime".to_string()); + let error = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(mismatched.clone()), + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::CONFLICT); + + mismatched.expected_revision = Some(1); + let (status, Json(replaced)) = + create_remote_runtime(State(api.clone()), Extension(actor), Json(mismatched)) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK); + assert_eq!(replaced.management.binding.unwrap().revision, 2); + + let unknown_identity = RuntimeIdentityMaterial::generate("unknown-runtime").unwrap(); + let unknown_revision = create_remote_runtime( + State(api.clone()), + Extension(test_owner_actor()), + Json(CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "unknown-runtime".to_string(), + public_key: unknown_identity.public_key, + }, + display_name: None, + endpoint: "https://8.8.4.4".to_string(), + expected_revision: Some(9), + }), + ) + .await + .unwrap_err(); + assert_eq!( + unknown_revision.into_response().status(), + StatusCode::CONFLICT + ); + + let cross_workspace = scoped_create_remote_runtime( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: "another-workspace".to_string(), + }), + Extension(test_owner_actor()), + Json(request), + ) + .await + .unwrap_err(); + assert_eq!( + cross_workspace.into_response().status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn runtime_connection_request_validation_bounds_browser_input() { + let identity = RuntimeIdentityMaterial::generate("team-runtime_1").unwrap(); + let ok = CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "team-runtime_1".to_string(), + public_key: identity.public_key, + }, + display_name: Some("Team Runtime".to_string()), + endpoint: "https://8.8.8.8".to_string(), + expected_revision: None, + }; + assert!(validate_runtime_connection_request(&ok).await.is_ok()); let bad_endpoint = CreateRemoteRuntimeRequest { - endpoint: "/tmp/socket".to_string(), + endpoint: "http://169.254.169.254/latest/meta-data".to_string(), ..ok }; - assert!(validate_runtime_connection_request(&bad_endpoint).is_err()); + assert!( + validate_runtime_connection_request(&bad_endpoint) + .await + .is_err() + ); } #[test] @@ -20159,7 +21066,8 @@ mod tests { #[tokio::test] async fn workspace_server_router_requires_identity_for_scoped_rest() { let temp = tempfile::tempdir().unwrap(); - let config = test_server_config(temp.path()); + let mut config = test_server_config(temp.path()); + config.backend_base_url = Some("https://backend.example.test".to_string()); let AuthConfig::Passkey { origin: expected_origin, .. @@ -20176,7 +21084,12 @@ mod tests { updated_at: "2026-01-01T00:00:00Z".to_owned(), }) .unwrap(); - let catalog = WorkspaceCatalogService::new(store.clone()); + let catalog = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&config.database_path), + )), + ); let repository = temp.path().join("repository"); std::fs::create_dir_all(&repository).unwrap(); assert!( @@ -20234,7 +21147,10 @@ mod tests { }) .unwrap(); let non_owner_token = seed_test_api_token(store.as_ref(), "repository-access-non-owner"); - let app = build_workspace_server_router(config, store).await.unwrap(); + let identity_material_root = workspace_signing_material_root(&config.database_path); + let app = build_workspace_server_router(config, store.clone()) + .await + .unwrap(); let uri = format!("/api/w/{}/workspace", workspace.workspace.workspace_id); let anonymous = app @@ -20563,6 +21479,109 @@ mod tests { .unwrap(); assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); + let identity_uri = format!( + "/api/w/{}/settings/workspace/signing-identity", + workspace.workspace.workspace_id + ); + let identity_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&identity_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(identity_response.status(), StatusCode::OK); + let identity_body = axum::body::to_bytes(identity_response.into_body(), usize::MAX) + .await + .unwrap(); + let identity_json: serde_json::Value = serde_json::from_slice(&identity_body).unwrap(); + assert_eq!(identity_json["identity"]["state"], "active"); + assert_eq!( + identity_json["public_bundle"]["workspace_id"], + workspace.workspace.workspace_id + ); + let identity_text = String::from_utf8(identity_body.to_vec()).unwrap(); + assert!(!identity_text.contains("private_key")); + assert!(!identity_text.contains("private_material_ref")); + let identity_non_owner = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&identity_uri) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {non_owner_token}"), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(identity_non_owner.status(), StatusCode::FORBIDDEN); + + let pending_identity = store + .get_workspace_signing_identity(&workspace.workspace.workspace_id) + .unwrap() + .unwrap(); + store + .with_conn_mut(|conn| { + conn.execute( + r#"UPDATE workspace_signing_identities + SET public_key = NULL, public_key_fingerprint = NULL, + state = 'pending_provisioning', provisioned_at = NULL + WHERE workspace_id = ?1"#, + rusqlite::params![workspace.workspace.workspace_id], + )?; + conn.execute( + "DELETE FROM workspace_signing_identity_audit WHERE workspace_id = ?1", + rusqlite::params![workspace.workspace.workspace_id], + )?; + conn.execute( + "DELETE FROM workspace_signing_identity_provisioning_operations WHERE workspace_id = ?1", + rusqlite::params![workspace.workspace.workspace_id], + )?; + Ok(()) + }) + .unwrap(); + FsWorkspaceSigningMaterialStore::new(identity_material_root) + .delete(&pending_identity.private_material_ref) + .unwrap(); + let provision_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("{identity_uri}/provision")) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(provision_response.status(), StatusCode::OK); + let provision_body = axum::body::to_bytes(provision_response.into_body(), usize::MAX) + .await + .unwrap(); + let provision_json: serde_json::Value = serde_json::from_slice(&provision_body).unwrap(); + assert_eq!(provision_json["identity"]["state"], "active"); + assert_eq!( + provision_json["identity"]["key_id"], + pending_identity.key_id + ); + let settings_uri = format!( "/api/w/{}/settings/workspace", workspace.workspace.workspace_id @@ -20783,7 +21802,12 @@ mod tests { template.static_assets_dir = Some(static_dir); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); let token = seed_test_api_token(store.as_ref(), "two-workspaces"); - let catalog = WorkspaceCatalogService::new(store.clone()); + let catalog = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&template.database_path), + )), + ); let workspace_a = catalog .create( WorkspaceCreateRequest { @@ -23465,38 +24489,41 @@ mod tests { } #[tokio::test] - async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() { + async fn workspace_runtime_trust_reveal_and_revoke_are_owner_only() { let temp = tempfile::tempdir().unwrap(); let api = test_api(temp.path()).await; let owner_account_id = format!("account-{TEST_WORKSPACE_ID}"); let owner = RequestActor { - user_id: "owner-user".to_string(), + user_id: "owner-user".to_owned(), account_id: owner_account_id.clone(), - handle: "owner".to_string(), - display_name: "Owner".to_string(), + handle: "owner".to_owned(), + display_name: "Owner".to_owned(), auth_method: ActorAuthMethod::BrowserSession, }; let non_owner = RequestActor { - user_id: "other-user".to_string(), - account_id: "other-account".to_string(), - handle: "other".to_string(), - display_name: "Other".to_string(), + user_id: "other-user".to_owned(), + account_id: "other-account".to_owned(), + handle: "other".to_owned(), + display_name: "Other".to_owned(), auth_method: ActorAuthMethod::ApiToken, }; - let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); - let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); - let third = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); let now = Utc::now().to_rfc3339(); api.store .put_workspace_runtime_binding_key( WorkspaceRuntimeBinding { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), - display_name: "Runtime A".to_string(), - base_url: "https://runtime.example".to_string(), - public_key: first.public_key, + workspace_id: TEST_WORKSPACE_ID.to_owned(), + runtime_id: "runtime-a".to_owned(), + display_name: "Runtime A".to_owned(), + base_url: "https://runtime.example".to_owned(), + public_key: identity.public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("WK-test".to_owned()), + workspace_key_generation: Some(1), created_at: now.clone(), updated_at: now, revoked_at: None, @@ -23507,147 +24534,65 @@ mod tests { .await .unwrap(); - 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()), AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), + workspace_id: TEST_WORKSPACE_ID.to_owned(), + runtime_id: "runtime-a".to_owned(), }), Extension(owner.clone()), ) .await .unwrap(); assert!(revealed.public_key.starts_with("yoi-ed25519-pub:v1:")); - let denied_reveal = scoped_reveal_runtime_trust_key( + let denied = scoped_reveal_runtime_trust_key( State(api.clone()), AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), + workspace_id: TEST_WORKSPACE_ID.to_owned(), + runtime_id: "runtime-a".to_owned(), }), Extension(non_owner.clone()), ) .await .unwrap_err(); - assert_eq!( - denied_reveal.into_response().status(), - StatusCode::FORBIDDEN - ); - - let response = scoped_put_runtime_trust_key( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), - }), - Extension(owner.clone()), - Json(PutRuntimeTrustKeyRequest { - public_key: second.public_key, - expected_revision: Some(1), - }), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let detail: WorkspaceRuntimeDetail = serde_json::from_slice(&body).unwrap(); - assert_eq!(detail.trust_key.revision, Some(2)); - assert_eq!( - detail.recent_audit[0].action, - RuntimeTrustAuditAction::Replaced - ); - - let stale = scoped_put_runtime_trust_key( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), - }), - Extension(owner.clone()), - Json(PutRuntimeTrustKeyRequest { - public_key: third.public_key, - expected_revision: Some(1), - }), - ) - .await - .unwrap(); - assert_eq!(stale.status(), StatusCode::CONFLICT); - let body = axum::body::to_bytes(stale.into_body(), usize::MAX) - .await - .unwrap(); - let conflict: RuntimeTrustConflictResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(conflict.error, RuntimeTrustConflictKind::StaleRevision); - assert_eq!(conflict.current_revision, Some(2)); + assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); let denied = scoped_revoke_runtime_trust_key( State(api.clone()), AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), + workspace_id: TEST_WORKSPACE_ID.to_owned(), + runtime_id: "runtime-a".to_owned(), }), Extension(non_owner), Json(RevokeRuntimeTrustKeyRequest { - expected_revision: 2, + expected_revision: 1, }), ) .await .unwrap_err(); assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); - - let revoked = scoped_revoke_runtime_trust_key( + let response = scoped_revoke_runtime_trust_key( State(api.clone()), AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: "runtime-a".to_string(), + workspace_id: TEST_WORKSPACE_ID.to_owned(), + runtime_id: "runtime-a".to_owned(), }), Extension(owner), Json(RevokeRuntimeTrustKeyRequest { - expected_revision: 2, + expected_revision: 1, }), ) .await .unwrap(); - assert_eq!(revoked.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); let binding = api .store .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a") .await .unwrap() .unwrap(); - assert_eq!(binding.binding_revision, 3); + assert_eq!(binding.binding_revision, 2); assert!(binding.revoked_at.is_some()); - let listed = workspace_runtime_resources_response(&api, TEST_WORKSPACE_ID) - .await - .unwrap(); - let listed_runtime = listed - .items - .iter() - .find(|resource| resource.runtime.runtime_id == "runtime-a") - .expect("revoked binding must remain listed"); - assert!(listed_runtime.management.config_managed); - let detail = workspace_runtime_detail(&api, TEST_WORKSPACE_ID, "runtime-a") - .await - .unwrap(); - assert_eq!(detail.trust_key.status, RuntimeTrustKeyStatus::Revoked); - assert!(detail.runtime.management.config_managed); - assert!( - !api.runtime_binding_expectations - .read() - .unwrap() - .contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string())) - ); } #[tokio::test] @@ -24148,16 +25093,15 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-remote").unwrap(); let mut config = test_server_config(temp.path()); + config.backend_base_url = Some("server-main".to_owned()); config.remote_runtime_sources.push(RemoteRuntimeConfig { runtime_id: "runtime-remote".to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), display_name: "Remote Runtime".to_string(), base_url: "https://runtime.invalid".to_string(), bearer_token: None, - auth: Some(RemoteRuntimeAuthConfig { - server_id: "server-main".to_string(), - server_private_key: identity.private_key.clone(), - }), + workspace_authorization: None, + strict_public_egress: false, cached_worker_creation_available: true, cached_os: "test".to_string(), cached_arch: "test".to_string(), @@ -24195,6 +25139,10 @@ mod tests { public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, @@ -24462,6 +25410,7 @@ mod tests { assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED); let mut revoked = trust; + revoked.state = StoredRuntimeBindingState::Revoked; revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string()); let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap(); authority @@ -25658,6 +26607,10 @@ mod tests { .public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25674,7 +26627,8 @@ mod tests { display_name: "Probe Runtime".to_string(), base_url: endpoint, bearer_token: Some("test-connection-token".to_string()), - auth: None, + workspace_authorization: None, + strict_public_egress: false, cached_worker_creation_available: true, cached_os: "linux".to_string(), cached_arch: "x86_64".to_string(), @@ -26788,6 +27742,10 @@ mod tests { public_key: identity.public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -26839,9 +27797,12 @@ mod tests { "POST", &runtimes_uri, Some(serde_json::json!({ - "runtime_id": "keyless-runtime", + "public_bundle": { + "identity_id": "keyless-runtime", + "public_key": "" + }, "display_name": "Keyless Runtime", - "endpoint": "https://keyless.runtime.invalid" + "endpoint": "https://8.8.8.8" })), StatusCode::BAD_REQUEST, ) @@ -26948,6 +27909,10 @@ mod tests { .public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -27014,7 +27979,9 @@ mod tests { ) -> serde_json::Value { let (endpoint, _server) = runtime_ping_stub(status, body).await; let dir = tempfile::tempdir().unwrap(); - let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint).await; + let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint) + .await + .layer(Extension(test_owner_actor())); post_json( app, &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"), @@ -27023,6 +27990,25 @@ mod tests { .await } + #[tokio::test] + async fn runtime_connection_verification_requires_workspace_owner() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let mut actor = test_owner_actor(); + actor.account_id = "account-other".to_string(); + let error = scoped_test_runtime_connection( + State(api), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "probe-runtime".to_string(), + }), + Extension(actor), + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); + } + #[tokio::test(flavor = "multi_thread")] async fn runtime_connection_test_reports_exact_compatible_protocol() { let response = run_runtime_connection_test( @@ -27035,6 +28021,9 @@ mod tests { .await; assert_eq!(response["status"], "compatible"); + assert_eq!(response["binding_revision"], 1); + assert_eq!(response["connection_state"], "verified"); + assert_eq!(response["verification"], serde_json::Value::Null); assert_eq!(response["failure_kind"], serde_json::Value::Null); assert_eq!( response["expected_protocol_version"], @@ -29436,8 +30425,14 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let status = response.status(); let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{uri}: {}", + String::from_utf8_lossy(&bytes) + ); serde_json::from_slice(&bytes).unwrap() } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index cdf6c2bb..84e6d870 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -18,11 +18,16 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 53; +const LATEST_SCHEMA_VERSION: i64 = 56; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; const WORKSPACE_DELETION_MIGRATION_NAME: &str = "durable Workspace deletion operations"; +const WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME: &str = "Workspace signing identity authority"; +const WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME: &str = + "Workspace Runtime binding state and identity mode"; +const WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME: &str = + "Workspace-signed Runtime verification evidence"; const MIGRATIONS: &[Migration] = &[ Migration { @@ -40,6 +45,21 @@ const MIGRATIONS: &[Migration] = &[ name: WORKSPACE_DELETION_MIGRATION_NAME, apply: migrate_workspace_deletion_v52_to_v53, }, + Migration { + version: 54, + name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME, + apply: migrate_workspace_signing_identity_v53_to_v54, + }, + Migration { + version: 55, + name: WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME, + apply: migrate_workspace_runtime_binding_state_v54_to_v55, + }, + Migration { + version: 56, + name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME, + apply: migrate_workspace_runtime_verification_v55_to_v56, + }, ]; #[derive(Clone, Copy)] @@ -135,6 +155,83 @@ pub struct WorkspaceBootstrapResult { pub replayed: bool, } +#[derive(Clone, PartialEq, Eq)] +pub struct WorkspaceSigningIdentityRecord { + pub workspace_id: String, + pub key_id: String, + pub algorithm: String, + pub public_key: Option, + pub public_key_fingerprint: Option, + pub private_material_ref: String, + pub revision: u64, + pub state: String, + pub created_at: String, + pub provisioned_at: Option, + pub updated_at: String, +} + +impl std::fmt::Debug for WorkspaceSigningIdentityRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkspaceSigningIdentityRecord") + .field("workspace_id", &self.workspace_id) + .field("key_id", &self.key_id) + .field("algorithm", &self.algorithm) + .field("public_key", &self.public_key) + .field("public_key_fingerprint", &self.public_key_fingerprint) + .field("private_material_ref", &"[REDACTED]") + .field("revision", &self.revision) + .field("state", &self.state) + .field("created_at", &self.created_at) + .field("provisioned_at", &self.provisioned_at) + .field("updated_at", &self.updated_at) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSigningIdentityActivation { + pub workspace_id: String, + pub key_id: String, + pub public_key: String, + pub public_key_fingerprint: String, + pub private_material_ref: String, + pub revision: u64, + pub provisioned_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSigningIdentityProvisioningOperation { + pub operation_key: String, + pub request_fingerprint: String, + pub operation_kind: String, + pub workspace_id: String, + pub key_id: String, + pub private_material_ref: String, + pub revision: u64, + pub actor_account_id: String, + pub state: String, + pub created_at: String, + pub completed_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceRuntimeVerificationEvidence { + pub workspace_id: String, + pub runtime_id: String, + pub binding_revision: u64, + pub workspace_key_id: String, + pub workspace_identity_revision: u64, + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + pub runtime_identity_revision: u64, + pub challenge_id: String, + pub state: String, + pub last_outcome: String, + pub verified_at: Option, + pub checked_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceRuntimeBinding { pub workspace_id: String, @@ -144,11 +241,49 @@ pub struct WorkspaceRuntimeBinding { pub public_key: String, pub public_key_fingerprint: String, pub binding_revision: u64, + pub state: WorkspaceRuntimeBindingState, + pub authentication_mode: WorkspaceRuntimeAuthenticationMode, + pub workspace_key_id: Option, + pub workspace_key_generation: Option, pub created_at: String, pub updated_at: String, pub revoked_at: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeBindingState { + Configured, + Verified, + Revoked, +} + +impl WorkspaceRuntimeBindingState { + fn as_str(self) -> &'static str { + match self { + Self::Configured => "configured", + Self::Verified => "verified", + Self::Revoked => "revoked", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeAuthenticationMode { + LegacyServerIssuer, + WorkspaceIdentity, +} + +impl WorkspaceRuntimeAuthenticationMode { + fn as_str(self) -> &'static str { + match self { + Self::LegacyServerIssuer => "legacy_server_issuer", + Self::WorkspaceIdentity => "workspace_identity", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkspaceRuntimeBindingUpsert { Created, @@ -602,9 +737,51 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { fn create_workspace_bootstrap( &self, record: &WorkspaceBootstrapRecord, + signing_identity: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, ) -> Result; + fn reserve_workspace_signing_identity_provisioning( + &self, + operation: &WorkspaceSigningIdentityProvisioningOperation, + ) -> Result; + fn get_workspace_signing_identity( + &self, + workspace_id: &str, + ) -> Result>; + fn activate_workspace_signing_identity( + &self, + activation: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, + actor_account_id: &str, + ) -> Result; fn workspace_runtime_binding_matches(&self, expected: &WorkspaceRuntimeBinding) -> Result; + fn workspace_runtime_verification_matches( + &self, + binding: &WorkspaceRuntimeBinding, + workspace_identity_revision: u64, + workspace_trust_generation: u64, + ) -> Result; + async fn get_workspace_runtime_verification( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result>; + async fn record_workspace_runtime_verification_attempt( + &self, + evidence: &WorkspaceRuntimeVerificationEvidence, + ) -> Result<()>; + async fn record_workspace_runtime_verification_outcome_if_current( + &self, + expected: &WorkspaceRuntimeVerificationEvidence, + state: &str, + outcome: &str, + checked_at: &str, + ) -> Result; + async fn complete_workspace_runtime_verification( + &self, + evidence: &WorkspaceRuntimeVerificationEvidence, + ) -> Result; async fn get_workspace_runtime_binding( &self, workspace_id: &str, @@ -1634,13 +1811,15 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let sql = if include_revoked { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 ORDER BY runtime_id ASC"# } else { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND revoked_at IS NULL ORDER BY runtime_id ASC"# @@ -1662,7 +1841,8 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -1688,7 +1868,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1701,7 +1882,11 @@ impl SqliteWorkspaceStore { && existing.display_name == record.display_name && existing.base_url == record.base_url && existing.public_key == record.public_key - && existing.public_key_fingerprint == record.public_key_fingerprint; + && existing.public_key_fingerprint == record.public_key_fingerprint + && existing.state == record.state + && existing.authentication_mode == record.authentication_mode + && existing.workspace_key_id == record.workspace_key_id + && existing.workspace_key_generation == record.workspace_key_generation; if exact_active_match { tx.commit()?; return Ok(WorkspaceRuntimeBindingUpsert::Unchanged); @@ -1716,7 +1901,9 @@ impl SqliteWorkspaceStore { r#"UPDATE workspace_runtime_bindings SET display_name = ?3, base_url = ?4, public_key = ?5, public_key_fingerprint = ?6, binding_revision = binding_revision + 1, - updated_at = ?7, revoked_at = ?8 + state = ?7, authentication_mode = ?8, + workspace_key_id = ?9, workspace_key_generation = ?10, + updated_at = ?11, revoked_at = ?12 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, @@ -1725,6 +1912,10 @@ impl SqliteWorkspaceStore { record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, record.updated_at, record.revoked_at, ], @@ -1736,8 +1927,9 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9)"#, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#, params![ record.workspace_id, record.runtime_id, @@ -1745,6 +1937,10 @@ impl SqliteWorkspaceStore { record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, record.created_at, record.updated_at, record.revoked_at, @@ -1768,7 +1964,7 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let changed = conn.execute( r#"UPDATE workspace_runtime_bindings - SET revoked_at = ?3, updated_at = ?3, + SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_revision = binding_revision + 1 WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, params![workspace_id, runtime_id, revoked_at], @@ -1795,7 +1991,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1805,8 +2002,14 @@ impl SqliteWorkspaceStore { if let Some(existing) = existing { if existing.revoked_at.is_none() + && existing.display_name == record.display_name + && existing.base_url == record.base_url && existing.public_key == record.public_key && existing.public_key_fingerprint == record.public_key_fingerprint + && existing.state == record.state + && existing.authentication_mode == record.authentication_mode + && existing.workspace_key_id == record.workspace_key_id + && existing.workspace_key_generation == record.workspace_key_generation { tx.commit()?; return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); @@ -1850,14 +2053,23 @@ impl SqliteWorkspaceStore { })?; tx.execute( r#"UPDATE workspace_runtime_bindings - SET public_key = ?3, public_key_fingerprint = ?4, - binding_revision = ?5, updated_at = ?6, revoked_at = NULL + SET display_name = ?3, base_url = ?4, + public_key = ?5, public_key_fingerprint = ?6, + state = ?7, authentication_mode = ?8, + workspace_key_id = ?9, workspace_key_generation = ?10, + binding_revision = ?11, updated_at = ?12, revoked_at = NULL WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, record.runtime_id, + record.display_name, + record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, next_revision, record.updated_at, ], @@ -1875,7 +2087,8 @@ impl SqliteWorkspaceStore { )?; let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1909,8 +2122,9 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, NULL)"#, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, NULL)"#, params![ record.workspace_id, record.runtime_id, @@ -1918,6 +2132,10 @@ impl SqliteWorkspaceStore { record.base_url, record.public_key, record.public_key_fingerprint, + record.state.as_str(), + record.authentication_mode.as_str(), + record.workspace_key_id, + record.workspace_key_generation, record.created_at, record.updated_at, ], @@ -1955,7 +2173,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -1981,7 +2200,7 @@ impl SqliteWorkspaceStore { .ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?; tx.execute( r#"UPDATE workspace_runtime_bindings - SET revoked_at = ?3, updated_at = ?3, binding_revision = ?4 + SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_revision = ?4 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id, revoked_at, next_revision], )?; @@ -1998,7 +2217,8 @@ impl SqliteWorkspaceStore { )?; let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -2009,6 +2229,297 @@ impl SqliteWorkspaceStore { }) } + pub fn workspace_runtime_verification_matches( + &self, + binding: &WorkspaceRuntimeBinding, + workspace_identity_revision: u64, + workspace_trust_generation: u64, + ) -> Result { + let Some(evidence) = + self.get_workspace_runtime_verification(&binding.workspace_id, &binding.runtime_id)? + else { + return Ok(false); + }; + Ok(evidence.state == "verified" + && evidence.last_outcome == "verified" + && evidence.verified_at.is_some() + && evidence.binding_revision == binding.binding_revision + && evidence.workspace_key_id == binding.workspace_key_id.as_deref().unwrap_or_default() + && evidence.workspace_identity_revision == workspace_identity_revision + && evidence.workspace_trust_generation == workspace_trust_generation + && evidence.runtime_public_key_fingerprint == binding.public_key_fingerprint + && evidence.runtime_identity_revision > 0) + } + + pub fn get_workspace_runtime_verification( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + self.with_conn(|conn| { + let evidence = conn + .query_row( + r#"SELECT workspace_id, runtime_id, binding_revision, workspace_key_id, + workspace_identity_revision, workspace_trust_generation, + runtime_public_key_fingerprint, runtime_identity_revision, + challenge_id, state, last_outcome, verified_at, checked_at + FROM workspace_runtime_verifications + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_verification, + ) + .optional()?; + if let Some(evidence) = &evidence { + validate_workspace_runtime_verification(evidence)?; + } + Ok(evidence) + }) + } + + pub fn record_workspace_runtime_verification_attempt( + &self, + evidence: &WorkspaceRuntimeVerificationEvidence, + ) -> Result<()> { + validate_workspace_runtime_verification(evidence)?; + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO workspace_runtime_verifications ( + workspace_id, runtime_id, binding_revision, workspace_key_id, + workspace_identity_revision, workspace_trust_generation, + runtime_public_key_fingerprint, runtime_identity_revision, + challenge_id, state, last_outcome, verified_at, checked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(workspace_id, runtime_id) DO UPDATE SET + binding_revision = excluded.binding_revision, + workspace_key_id = excluded.workspace_key_id, + workspace_identity_revision = excluded.workspace_identity_revision, + workspace_trust_generation = excluded.workspace_trust_generation, + runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint, + runtime_identity_revision = excluded.runtime_identity_revision, + challenge_id = excluded.challenge_id, + state = CASE + WHEN workspace_runtime_verifications.state = 'verified' + AND workspace_runtime_verifications.binding_revision = excluded.binding_revision + AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id + AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision + AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation + AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint + AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision + THEN workspace_runtime_verifications.state + ELSE excluded.state + END, + last_outcome = excluded.last_outcome, + verified_at = CASE + WHEN workspace_runtime_verifications.state = 'verified' + AND workspace_runtime_verifications.binding_revision = excluded.binding_revision + AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id + AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision + AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation + AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint + AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision + THEN workspace_runtime_verifications.verified_at + ELSE excluded.verified_at + END, + checked_at = excluded.checked_at"#, + params![ + evidence.workspace_id, + evidence.runtime_id, + evidence.binding_revision, + evidence.workspace_key_id, + evidence.workspace_identity_revision, + evidence.workspace_trust_generation, + evidence.runtime_public_key_fingerprint, + evidence.runtime_identity_revision, + evidence.challenge_id, + evidence.state, + evidence.last_outcome, + evidence.verified_at, + evidence.checked_at, + ], + )?; + Ok(()) + }) + } + + pub fn record_workspace_runtime_verification_outcome_if_current( + &self, + expected: &WorkspaceRuntimeVerificationEvidence, + state: &str, + outcome: &str, + checked_at: &str, + ) -> Result { + validate_workspace_runtime_verification(expected)?; + if !matches!(state, "pending" | "verified" | "failed") { + return Err(Error::Store( + "invalid Workspace Runtime verification state".to_string(), + )); + } + if !matches!( + outcome, + "challenge_issued" | "verified" | "verification_failed" | "connectivity_failed" + ) { + return Err(Error::Store( + "invalid Workspace Runtime verification outcome".to_string(), + )); + } + if checked_at.is_empty() || checked_at.len() > 128 { + return Err(Error::Store( + "invalid Workspace Runtime verification checked_at".to_string(), + )); + } + self.with_conn(|conn| { + let changed = conn.execute( + r#"UPDATE workspace_runtime_verifications + SET state = CASE WHEN state = 'verified' THEN state ELSE ?10 END, + last_outcome = ?11, + checked_at = ?12 + WHERE workspace_id = ?1 AND runtime_id = ?2 + AND binding_revision = ?3 + AND workspace_key_id = ?4 + AND workspace_identity_revision = ?5 + AND workspace_trust_generation = ?6 + AND runtime_public_key_fingerprint = ?7 + AND runtime_identity_revision = ?8 + AND challenge_id = ?9"#, + params![ + expected.workspace_id, + expected.runtime_id, + expected.binding_revision, + expected.workspace_key_id, + expected.workspace_identity_revision, + expected.workspace_trust_generation, + expected.runtime_public_key_fingerprint, + expected.runtime_identity_revision, + expected.challenge_id, + state, + outcome, + checked_at, + ], + )?; + Ok(changed == 1) + }) + } + + pub fn complete_workspace_runtime_verification( + &self, + evidence: &WorkspaceRuntimeVerificationEvidence, + ) -> Result { + validate_workspace_runtime_verification(evidence)?; + if evidence.state != "verified" || evidence.verified_at.is_none() { + return Err(Error::Store( + "completed Runtime verification evidence must be verified".to_string(), + )); + } + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current_attempt = tx.query_row( + r#"SELECT EXISTS( + SELECT 1 FROM workspace_runtime_verifications + WHERE workspace_id = ?1 AND runtime_id = ?2 + AND binding_revision = ?3 + AND workspace_key_id = ?4 + AND workspace_identity_revision = ?5 + AND workspace_trust_generation = ?6 + AND runtime_public_key_fingerprint = ?7 + AND runtime_identity_revision = ?8 + AND challenge_id = ?9 + )"#, + params![ + evidence.workspace_id, + evidence.runtime_id, + evidence.binding_revision, + evidence.workspace_key_id, + evidence.workspace_identity_revision, + evidence.workspace_trust_generation, + evidence.runtime_public_key_fingerprint, + evidence.runtime_identity_revision, + evidence.challenge_id, + ], + |row| row.get::<_, bool>(0), + )?; + if !current_attempt { + return Err(Error::RuntimeBindingConflict( + "Runtime verification attempt was superseded".to_string(), + )); + } + let changed = tx.execute( + r#"UPDATE workspace_runtime_bindings + SET state = 'verified', updated_at = ?7 + WHERE workspace_id = ?1 AND runtime_id = ?2 + AND binding_revision = ?3 + AND state IN ('configured', 'verified') + AND authentication_mode = 'workspace_identity' + AND workspace_key_id = ?4 + AND workspace_key_generation = ?5 + AND public_key_fingerprint = ?6 + AND revoked_at IS NULL"#, + params![ + evidence.workspace_id, + evidence.runtime_id, + evidence.binding_revision, + evidence.workspace_key_id, + evidence.workspace_trust_generation, + evidence.runtime_public_key_fingerprint, + evidence.checked_at, + ], + )?; + if changed != 1 { + return Err(Error::RuntimeBindingConflict( + "Runtime verification evidence no longer matches the configured binding" + .to_string(), + )); + } + tx.execute( + r#"INSERT INTO workspace_runtime_verifications ( + workspace_id, runtime_id, binding_revision, workspace_key_id, + workspace_identity_revision, workspace_trust_generation, + runtime_public_key_fingerprint, runtime_identity_revision, + challenge_id, state, last_outcome, verified_at, checked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(workspace_id, runtime_id) DO UPDATE SET + binding_revision = excluded.binding_revision, + workspace_key_id = excluded.workspace_key_id, + workspace_identity_revision = excluded.workspace_identity_revision, + workspace_trust_generation = excluded.workspace_trust_generation, + runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint, + runtime_identity_revision = excluded.runtime_identity_revision, + challenge_id = excluded.challenge_id, + state = excluded.state, + last_outcome = excluded.last_outcome, + verified_at = excluded.verified_at, + checked_at = excluded.checked_at"#, + params![ + evidence.workspace_id, + evidence.runtime_id, + evidence.binding_revision, + evidence.workspace_key_id, + evidence.workspace_identity_revision, + evidence.workspace_trust_generation, + evidence.runtime_public_key_fingerprint, + evidence.runtime_identity_revision, + evidence.challenge_id, + evidence.state, + evidence.last_outcome, + evidence.verified_at, + evidence.checked_at, + ], + )?; + let binding = tx.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![evidence.workspace_id, evidence.runtime_id], + read_workspace_runtime_binding, + )?; + tx.commit()?; + Ok(binding) + }) + } + pub fn list_workspace_runtime_binding_audit( &self, workspace_id: &str, @@ -2205,6 +2716,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) VALUES (?1, 1, 'English', ?2, ?3)"#, params![record.workspace_id, record.created_at, record.updated_at], )?; + tx.execute( + r#"INSERT OR IGNORE INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES ( + ?1, 'WK-' || lower(hex(randomblob(16))), 'ed25519', NULL, NULL, + 'workspace-signing/' || ?1 || '/ed25519-v1', 1, + 'pending_provisioning', ?2, NULL, ?3 + )"#, + params![record.workspace_id, record.created_at, record.updated_at], + )?; tx.commit()?; Ok(()) })?; @@ -2227,8 +2749,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn create_workspace_bootstrap( &self, record: &WorkspaceBootstrapRecord, + signing_identity: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, ) -> Result { validate_repository_record_identity(&record.repository)?; + if signing_identity.workspace_id != record.workspace.workspace_id { + return Err(Error::Store( + "Workspace signing identity does not belong to the Workspace bootstrap".to_string(), + )); + } self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let owner_kind = tx @@ -2272,6 +2801,43 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![workspace.workspace_id, record.repository.repository_key], read_repository_record, )?; + let persisted_identity = tx.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![workspace.workspace_id], + read_workspace_signing_identity, + )?; + if persisted_identity.workspace_id != signing_identity.workspace_id + || persisted_identity.key_id != signing_identity.key_id + || persisted_identity.public_key.as_deref() + != Some(signing_identity.public_key.as_str()) + || persisted_identity.public_key_fingerprint.as_deref() + != Some(signing_identity.public_key_fingerprint.as_str()) + || persisted_identity.private_material_ref + != signing_identity.private_material_ref + || persisted_identity.revision != signing_identity.revision + || persisted_identity.state != "active" + { + return Err(Error::Store( + "Workspace create replay signing identity does not match persisted authority" + .to_string(), + )); + } + let provisioning_state: Option = tx + .query_row( + "SELECT state FROM workspace_signing_identity_provisioning_operations WHERE operation_key = ?1", + params![identity_provisioning_operation_key], + |row| row.get(0), + ) + .optional()?; + if provisioning_state.as_deref() != Some("completed") { + return Err(Error::Store( + "Workspace create replay lacks completed signing identity provisioning evidence" + .to_string(), + )); + } let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)? .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? .snapshot @@ -2370,6 +2936,58 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? .snapshot .revision; + tx.execute( + r#"INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES (?1, ?2, 'ed25519', ?3, ?4, ?5, ?6, 'active', ?7, ?8, ?8)"#, + params![ + signing_identity.workspace_id, + signing_identity.key_id, + signing_identity.public_key, + signing_identity.public_key_fingerprint, + signing_identity.private_material_ref, + signing_identity.revision, + record.workspace.created_at, + signing_identity.provisioned_at, + ], + )?; + tx.execute( + r#"INSERT INTO workspace_signing_identity_audit ( + event_id, workspace_id, key_id, action, revision, + public_key_fingerprint, actor_account_id, created_at + ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#, + params![ + uuid::Uuid::now_v7().to_string(), + signing_identity.workspace_id, + signing_identity.key_id, + signing_identity.revision, + signing_identity.public_key_fingerprint, + record.workspace.owner_account_id, + signing_identity.provisioned_at, + ], + )?; + let completed = tx.execute( + r#"UPDATE workspace_signing_identity_provisioning_operations + SET state = 'completed', completed_at = ?2 + WHERE operation_key = ?1 AND state = 'pending' + AND workspace_id = ?3 AND key_id = ?4 + AND private_material_ref = ?5 AND revision = ?6"#, + params![ + identity_provisioning_operation_key, + signing_identity.provisioned_at, + signing_identity.workspace_id, + signing_identity.key_id, + signing_identity.private_material_ref, + signing_identity.revision, + ], + )?; + if completed != 1 { + return Err(Error::Store( + "Workspace signing identity provisioning reservation is missing or inconsistent" + .to_string(), + )); + } tx.execute( r#"INSERT INTO workspace_create_operations ( operation_key, request_fingerprint, workspace_id, created_at @@ -2391,6 +3009,245 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn reserve_workspace_signing_identity_provisioning( + &self, + operation: &WorkspaceSigningIdentityProvisioningOperation, + ) -> Result { + for (label, value) in [ + ("operation_key", operation.operation_key.as_str()), + ( + "request_fingerprint", + operation.request_fingerprint.as_str(), + ), + ("workspace_id", operation.workspace_id.as_str()), + ("key_id", operation.key_id.as_str()), + ( + "private_material_ref", + operation.private_material_ref.as_str(), + ), + ("actor_account_id", operation.actor_account_id.as_str()), + ] { + validate_non_empty(label, value)?; + } + if !matches!( + operation.operation_kind.as_str(), + "workspace_create" | "existing_workspace" + ) || operation.revision == 0 + { + return Err(Error::Store( + "Workspace signing identity provisioning reservation is invalid".to_string(), + )); + } + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(existing) = tx + .query_row( + r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id, + key_id, private_material_ref, revision, actor_account_id, state, + created_at, completed_at + FROM workspace_signing_identity_provisioning_operations + WHERE operation_key = ?1"#, + params![operation.operation_key], + read_workspace_signing_identity_provisioning_operation, + ) + .optional()? + { + if existing.request_fingerprint != operation.request_fingerprint + || existing.operation_kind != operation.operation_kind + { + return Err(Error::WorkspaceConfigConflict( + "Workspace signing identity operation key was already used with different input" + .to_string(), + )); + } + tx.commit()?; + return Ok(existing); + } + tx.execute( + r#"INSERT INTO workspace_signing_identity_provisioning_operations ( + operation_key, request_fingerprint, operation_kind, workspace_id, + key_id, private_material_ref, revision, actor_account_id, state, + created_at, completed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, NULL)"#, + params![ + operation.operation_key, + operation.request_fingerprint, + operation.operation_kind, + operation.workspace_id, + operation.key_id, + operation.private_material_ref, + operation.revision, + operation.actor_account_id, + operation.created_at, + ], + ) + .map_err(|error| { + if matches!(error, rusqlite::Error::SqliteFailure(_, _)) { + Error::WorkspaceConfigConflict( + "Workspace already has a signing identity provisioning operation" + .to_string(), + ) + } else { + Error::from(error) + } + })?; + tx.commit()?; + Ok(operation.clone()) + }) + } + + fn get_workspace_signing_identity( + &self, + workspace_id: &str, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + self.with_conn(|conn| { + conn.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![workspace_id], + read_workspace_signing_identity, + ) + .optional() + .map_err(Error::from) + }) + } + + fn activate_workspace_signing_identity( + &self, + activation: &WorkspaceSigningIdentityActivation, + identity_provisioning_operation_key: &str, + actor_account_id: &str, + ) -> Result { + validate_identifier("workspace_id", &activation.workspace_id)?; + validate_non_empty( + "identity_provisioning_operation_key", + identity_provisioning_operation_key, + )?; + validate_identifier("actor_account_id", actor_account_id)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let operation = tx + .query_row( + r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id, + key_id, private_material_ref, revision, actor_account_id, state, + created_at, completed_at + FROM workspace_signing_identity_provisioning_operations + WHERE operation_key = ?1"#, + params![identity_provisioning_operation_key], + read_workspace_signing_identity_provisioning_operation, + ) + .optional()? + .ok_or_else(|| { + Error::Store( + "Workspace signing identity provisioning operation is missing".to_string(), + ) + })?; + if operation.workspace_id != activation.workspace_id + || operation.key_id != activation.key_id + || operation.private_material_ref != activation.private_material_ref + || operation.revision != activation.revision + || operation.actor_account_id != actor_account_id + { + return Err(Error::Store( + "Workspace signing identity provisioning operation is inconsistent".to_string(), + )); + } + let existing = tx.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![activation.workspace_id], + read_workspace_signing_identity, + )?; + if existing.key_id != activation.key_id + || existing.private_material_ref != activation.private_material_ref + || existing.revision != activation.revision + { + return Err(Error::Store( + "Workspace signing identity activation does not match persisted metadata" + .to_string(), + )); + } + if existing.state == "active" { + if existing.public_key.as_deref() != Some(activation.public_key.as_str()) + || existing.public_key_fingerprint.as_deref() + != Some(activation.public_key_fingerprint.as_str()) + { + return Err(Error::Store( + "Workspace signing identity replay does not match active authority" + .to_string(), + )); + } + tx.execute( + r#"UPDATE workspace_signing_identity_provisioning_operations + SET state = 'completed', completed_at = COALESCE(completed_at, ?2) + WHERE operation_key = ?1"#, + params![ + identity_provisioning_operation_key, + activation.provisioned_at + ], + )?; + tx.commit()?; + return Ok(existing); + } + if existing.state != "pending_provisioning" { + return Err(Error::Store( + "Workspace signing identity has an unknown lifecycle state".to_string(), + )); + } + tx.execute( + r#"UPDATE workspace_signing_identities + SET public_key = ?2, public_key_fingerprint = ?3, state = 'active', + provisioned_at = ?4, updated_at = ?4 + WHERE workspace_id = ?1 AND state = 'pending_provisioning'"#, + params![ + activation.workspace_id, + activation.public_key, + activation.public_key_fingerprint, + activation.provisioned_at, + ], + )?; + tx.execute( + r#"INSERT INTO workspace_signing_identity_audit ( + event_id, workspace_id, key_id, action, revision, + public_key_fingerprint, actor_account_id, created_at + ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#, + params![ + uuid::Uuid::now_v7().to_string(), + activation.workspace_id, + activation.key_id, + activation.revision, + activation.public_key_fingerprint, + actor_account_id, + activation.provisioned_at, + ], + )?; + tx.execute( + r#"UPDATE workspace_signing_identity_provisioning_operations + SET state = 'completed', completed_at = ?2 + WHERE operation_key = ?1"#, + params![ + identity_provisioning_operation_key, + activation.provisioned_at + ], + )?; + let activated = tx.query_row( + r#"SELECT workspace_id, key_id, algorithm, public_key, + public_key_fingerprint, private_material_ref, revision, state, + created_at, provisioned_at, updated_at + FROM workspace_signing_identities WHERE workspace_id = ?1"#, + params![activation.workspace_id], + read_workspace_signing_identity, + )?; + tx.commit()?; + Ok(activated) + }) + } + fn workspace_runtime_binding_matches( &self, expected: &WorkspaceRuntimeBinding, @@ -2403,6 +3260,54 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .is_some_and(|binding| binding == *expected && binding.revoked_at.is_none())) } + fn workspace_runtime_verification_matches( + &self, + binding: &WorkspaceRuntimeBinding, + workspace_identity_revision: u64, + workspace_trust_generation: u64, + ) -> Result { + SqliteWorkspaceStore::workspace_runtime_verification_matches( + self, + binding, + workspace_identity_revision, + workspace_trust_generation, + ) + } + + async fn get_workspace_runtime_verification( + &self, + workspace_id: &str, + runtime_id: &str, + ) -> Result> { + SqliteWorkspaceStore::get_workspace_runtime_verification(self, workspace_id, runtime_id) + } + + async fn record_workspace_runtime_verification_attempt( + &self, + evidence: &WorkspaceRuntimeVerificationEvidence, + ) -> Result<()> { + SqliteWorkspaceStore::record_workspace_runtime_verification_attempt(self, evidence) + } + + async fn record_workspace_runtime_verification_outcome_if_current( + &self, + expected: &WorkspaceRuntimeVerificationEvidence, + state: &str, + outcome: &str, + checked_at: &str, + ) -> Result { + SqliteWorkspaceStore::record_workspace_runtime_verification_outcome_if_current( + self, expected, state, outcome, checked_at, + ) + } + + async fn complete_workspace_runtime_verification( + &self, + evidence: &WorkspaceRuntimeVerificationEvidence, + ) -> Result { + SqliteWorkspaceStore::complete_workspace_runtime_verification(self, evidence) + } + async fn get_workspace_runtime_binding( &self, workspace_id: &str, @@ -5806,6 +6711,46 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result, +) -> rusqlite::Result { + let revision: i64 = row.get(6)?; + Ok(WorkspaceSigningIdentityRecord { + workspace_id: row.get(0)?, + key_id: row.get(1)?, + algorithm: row.get(2)?, + public_key: row.get(3)?, + public_key_fingerprint: row.get(4)?, + private_material_ref: row.get(5)?, + revision: u64::try_from(revision) + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?, + state: row.get(7)?, + created_at: row.get(8)?, + provisioned_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +fn read_workspace_signing_identity_provisioning_operation( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let revision: i64 = row.get(6)?; + Ok(WorkspaceSigningIdentityProvisioningOperation { + operation_key: row.get(0)?, + request_fingerprint: row.get(1)?, + operation_kind: row.get(2)?, + workspace_id: row.get(3)?, + key_id: row.get(4)?, + private_material_ref: row.get(5)?, + revision: u64::try_from(revision) + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?, + actor_account_id: row.get(7)?, + state: row.get(8)?, + created_at: row.get(9)?, + completed_at: row.get(10)?, + }) +} + fn repository_registration_intent_matches( existing: &RepositoryRecord, requested: &RepositoryRecord, @@ -5868,9 +6813,89 @@ fn account_select_sql(where_clause: &str) -> String { ) } +fn read_workspace_runtime_verification( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + Ok(WorkspaceRuntimeVerificationEvidence { + workspace_id: row.get(0)?, + runtime_id: row.get(1)?, + binding_revision: row.get(2)?, + workspace_key_id: row.get(3)?, + workspace_identity_revision: row.get(4)?, + workspace_trust_generation: row.get(5)?, + runtime_public_key_fingerprint: row.get(6)?, + runtime_identity_revision: row.get(7)?, + challenge_id: row.get(8)?, + state: row.get(9)?, + last_outcome: row.get(10)?, + verified_at: row.get(11)?, + checked_at: row.get(12)?, + }) +} + +fn validate_workspace_runtime_verification( + evidence: &WorkspaceRuntimeVerificationEvidence, +) -> Result<()> { + for (field, value) in [ + ("workspace_id", evidence.workspace_id.as_str()), + ("runtime_id", evidence.runtime_id.as_str()), + ("workspace_key_id", evidence.workspace_key_id.as_str()), + ("challenge_id", evidence.challenge_id.as_str()), + ] { + validate_identifier(field, value)?; + } + if evidence.binding_revision == 0 + || evidence.workspace_identity_revision == 0 + || evidence.workspace_trust_generation == 0 + || evidence.runtime_identity_revision == 0 + { + return Err(Error::InvalidInput( + "Runtime verification revisions and generations must be positive".to_string(), + )); + } + validate_non_empty( + "runtime_public_key_fingerprint", + &evidence.runtime_public_key_fingerprint, + )?; + validate_non_empty("verification state", &evidence.state)?; + if !matches!( + evidence.last_outcome.as_str(), + "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed" + ) { + return Err(Error::InvalidInput( + "Runtime verification outcome is invalid".to_string(), + )); + } + validate_non_empty("checked_at", &evidence.checked_at)?; + Ok(()) +} + fn read_workspace_runtime_binding( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { + let state = match row.get::<_, String>(7)?.as_str() { + "configured" => WorkspaceRuntimeBindingState::Configured, + "verified" => WorkspaceRuntimeBindingState::Verified, + "revoked" => WorkspaceRuntimeBindingState::Revoked, + value => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + format!("unknown Workspace Runtime binding state {value}").into(), + )); + } + }; + let authentication_mode = match row.get::<_, String>(8)?.as_str() { + "legacy_server_issuer" => WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + "workspace_identity" => WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + value => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 8, + rusqlite::types::Type::Text, + format!("unknown Workspace Runtime authentication mode {value}").into(), + )); + } + }; Ok(WorkspaceRuntimeBinding { workspace_id: row.get(0)?, runtime_id: row.get(1)?, @@ -5879,9 +6904,13 @@ fn read_workspace_runtime_binding( public_key: row.get(4)?, public_key_fingerprint: row.get(5)?, binding_revision: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - revoked_at: row.get(9)?, + state, + authentication_mode, + workspace_key_id: row.get(9)?, + workspace_key_generation: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + revoked_at: row.get(13)?, }) } @@ -5937,6 +6966,39 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding) } record.public_key = canonical; record.public_key_fingerprint = fingerprint; + match record.authentication_mode { + WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer => { + if record.workspace_key_id.is_some() || record.workspace_key_generation.is_some() { + return Err(Error::InvalidInput( + "legacy Server issuer Runtime bindings must not carry Workspace key metadata" + .into(), + )); + } + } + WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity => { + let key_id = record.workspace_key_id.as_deref().ok_or_else(|| { + Error::InvalidInput( + "Workspace identity Runtime bindings require workspace_key_id".into(), + ) + })?; + validate_identifier("workspace_key_id", key_id)?; + if record + .workspace_key_generation + .is_none_or(|generation| generation == 0) + { + return Err(Error::InvalidInput( + "Workspace identity Runtime bindings require a positive workspace_key_generation" + .into(), + )); + } + } + } + let revoked = record.revoked_at.is_some(); + if (record.state == WorkspaceRuntimeBindingState::Revoked) != revoked { + return Err(Error::InvalidInput( + "Runtime binding state and revoked_at must agree".into(), + )); + } Ok(()) } @@ -7036,12 +8098,320 @@ fn migrate_workspace_deletion_v52_to_v53(conn: &Connection) -> Result<()> { verify_workspace_deletion_schema(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![LATEST_SCHEMA_VERSION, WORKSPACE_DELETION_MIGRATION_NAME], + params![53_i64, WORKSPACE_DELETION_MIGRATION_NAME], )?; tx.commit()?; Ok(()) } +fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 53 { + return Err(Error::Store(format!( + "expected schema version 53 before {WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); + CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); + INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) + SELECT workspace_id, + 'WK-' || lower(hex(randomblob(16))), + 'ed25519', NULL, NULL, + 'workspace-signing/' || workspace_id || '/ed25519-v1', + 1, 'pending_provisioning', created_at, NULL, updated_at + FROM workspaces; + "#, + )?; + verify_workspace_signing_identity_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![54_i64, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_binding_state_v54_to_v55(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 54 { + return Err(Error::Store(format!( + "expected schema version 54 before {WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let columns = table_columns(&tx, "workspace_runtime_bindings")?; + let lifecycle_columns = [ + "state", + "authentication_mode", + "workspace_key_id", + "workspace_key_generation", + ]; + let present = lifecycle_columns + .iter() + .filter(|column| columns.iter().any(|existing| existing == **column)) + .count(); + if present == 0 { + tx.execute_batch( + r#" + CREATE TABLE workspace_runtime_bindings_v55 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), + state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), + authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), + workspace_key_id TEXT, + workspace_key_generation INTEGER CHECK (workspace_key_generation > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id), + UNIQUE (workspace_id, public_key_fingerprint), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + CHECK ( + (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) + ); + INSERT INTO workspace_runtime_bindings_v55 ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) + SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, + CASE WHEN revoked_at IS NULL THEN 'verified' ELSE 'revoked' END, + 'legacy_server_issuer', NULL, NULL, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings; + DROP TABLE workspace_runtime_bindings; + ALTER TABLE workspace_runtime_bindings_v55 RENAME TO workspace_runtime_bindings; + CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); + "#, + )?; + } else if present != lifecycle_columns.len() { + return Err(Error::Store( + "workspace_runtime_bindings has a partially applied lifecycle schema".to_string(), + )); + } + verify_workspace_runtime_binding_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![55_i64, WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_verification_v55_to_v56(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 55 { + return Err(Error::Store(format!( + "expected schema version 55 before {WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS workspace_runtime_verifications ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_revision INTEGER NOT NULL CHECK(binding_revision > 0), + workspace_key_id TEXT NOT NULL, + workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0), + workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0), + runtime_public_key_fingerprint TEXT NOT NULL, + runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0), + challenge_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), + last_outcome TEXT NOT NULL, + verified_at TEXT, + checked_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, runtime_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) + ON DELETE CASCADE, + CHECK((state = 'verified' AND verified_at IS NOT NULL) + OR (state != 'verified' AND verified_at IS NULL)) + ); + CREATE INDEX IF NOT EXISTS workspace_runtime_verifications_state_idx + ON workspace_runtime_verifications(workspace_id, state, checked_at DESC); + "#, + )?; + verify_workspace_runtime_verification_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![56_i64, WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn verify_workspace_runtime_verification_schema(conn: &Connection) -> Result<()> { + let actual = table_columns(conn, "workspace_runtime_verifications")? + .into_iter() + .collect::>(); + let expected = [ + "workspace_id", + "runtime_id", + "binding_revision", + "workspace_key_id", + "workspace_identity_revision", + "workspace_trust_generation", + "runtime_public_key_fingerprint", + "runtime_identity_revision", + "challenge_id", + "state", + "last_outcome", + "verified_at", + "checked_at", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if actual != expected { + return Err(Error::Store(format!( + "workspace_runtime_verifications schema does not match schema-56: {actual:?}" + ))); + } + Ok(()) +} + +fn verify_workspace_signing_identity_schema(conn: &Connection) -> Result<()> { + for (table, expected) in [ + ( + "workspace_signing_identities", + vec![ + "workspace_id", + "key_id", + "algorithm", + "public_key", + "public_key_fingerprint", + "private_material_ref", + "revision", + "state", + "created_at", + "provisioned_at", + "updated_at", + ], + ), + ( + "workspace_signing_identity_provisioning_operations", + vec![ + "operation_key", + "request_fingerprint", + "operation_kind", + "workspace_id", + "key_id", + "private_material_ref", + "revision", + "actor_account_id", + "state", + "created_at", + "completed_at", + ], + ), + ( + "workspace_signing_identity_audit", + vec![ + "event_id", + "workspace_id", + "key_id", + "action", + "revision", + "public_key_fingerprint", + "actor_account_id", + "created_at", + ], + ), + ] { + let actual = table_columns(conn, table)? + .into_iter() + .collect::>(); + let expected = expected + .into_iter() + .map(str::to_string) + .collect::>(); + if actual != expected { + return Err(Error::Store(format!( + "{table} schema does not match schema-54" + ))); + } + } + let pending_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM workspaces w LEFT JOIN workspace_signing_identities i ON i.workspace_id = w.workspace_id WHERE i.workspace_id IS NULL", + [], + |row| row.get(0), + )?; + if pending_count != 0 { + return Err(Error::Store( + "schema-54 failed to initialize every existing Workspace signing identity as pending" + .to_string(), + )); + } + Ok(()) +} + fn verify_workspace_deletion_schema(conn: &Connection) -> Result<()> { let columns = table_columns(conn, "workspace_deletion_operations")? .into_iter() @@ -7088,7 +8458,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { let columns = table_columns(conn, "workspace_runtime_bindings")? .into_iter() .collect::>(); - let expected = [ + let has_binding_state = columns.contains("state"); + let mut expected = [ "workspace_id", "runtime_id", "display_name", @@ -7103,11 +8474,42 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { .into_iter() .map(str::to_string) .collect::>(); + if has_binding_state { + expected.extend( + [ + "state", + "authentication_mode", + "workspace_key_id", + "workspace_key_generation", + ] + .into_iter() + .map(str::to_string), + ); + } if columns != expected { return Err(Error::Store( - "workspace_runtime_bindings schema does not match schema-52".to_string(), + "workspace_runtime_bindings schema does not match the supported schema".to_string(), )); } + if has_binding_state { + let invalid_lifecycle_count = conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings + WHERE state NOT IN ('configured', 'verified', 'revoked') + OR authentication_mode NOT IN ('legacy_server_issuer', 'workspace_identity') + OR (authentication_mode = 'workspace_identity' AND + (workspace_key_id IS NULL OR workspace_key_generation IS NULL)) + OR (state = 'revoked' AND revoked_at IS NULL) + OR (state <> 'revoked' AND revoked_at IS NOT NULL)", + [], + |row| row.get::<_, i64>(0), + )?; + if invalid_lifecycle_count != 0 { + return Err(Error::Store( + "workspace_runtime_bindings contains invalid binding lifecycle metadata" + .to_string(), + )); + } + } let revision_default = conn.query_row( "SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'", [], @@ -7184,24 +8586,62 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { .to_string(), )); } - let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at - FROM workspace_runtime_bindings"#, - )?; - let rows = stmt.query_map([], read_workspace_runtime_binding)?; - for row in rows { - let binding = row?; - let mut normalized = binding.clone(); - normalize_workspace_runtime_binding_key(&mut normalized)?; - if normalized.public_key != binding.public_key - || normalized.public_key_fingerprint != binding.public_key_fingerprint - { - return Err(Error::Store(format!( - "Runtime binding `{}/{}` has non-canonical trust content", - binding.workspace_id, binding.runtime_id - ))); + if has_binding_state { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings"#, + )?; + let rows = stmt.query_map([], read_workspace_runtime_binding)?; + for row in rows { + verify_canonical_workspace_runtime_binding(row?)?; } + } else { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(WorkspaceRuntimeBinding { + workspace_id: row.get(0)?, + runtime_id: row.get(1)?, + display_name: row.get(2)?, + base_url: row.get(3)?, + public_key: row.get(4)?, + public_key_fingerprint: row.get(5)?, + binding_revision: row.get(6)?, + state: if row.get::<_, Option>(9)?.is_some() { + WorkspaceRuntimeBindingState::Revoked + } else { + WorkspaceRuntimeBindingState::Verified + }, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, + created_at: row.get(7)?, + updated_at: row.get(8)?, + revoked_at: row.get(9)?, + }) + })?; + for row in rows { + verify_canonical_workspace_runtime_binding(row?)?; + } + } + Ok(()) +} + +fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> { + let mut normalized = binding.clone(); + normalize_workspace_runtime_binding_key(&mut normalized)?; + if normalized.public_key != binding.public_key + || normalized.public_key_fingerprint != binding.public_key_fingerprint + { + return Err(Error::Store(format!( + "Runtime binding `{}/{}` has non-canonical trust content", + binding.workspace_id, binding.runtime_id + ))); } Ok(()) } @@ -7759,7 +9199,9 @@ fn apply_migrations(conn: &Connection) -> Result<()> { verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; verify_workspace_runtime_binding_schema(conn)?; - verify_workspace_deletion_schema(conn) + verify_workspace_runtime_verification_schema(conn)?; + verify_workspace_deletion_schema(conn)?; + verify_workspace_signing_identity_schema(conn) } fn table_exists(conn: &Connection, table_name: &str) -> Result { @@ -7852,6 +9294,10 @@ mod tests { create_latest_workspace_schema(&conn).unwrap(); conn.execute_batch( r#" + DROP INDEX workspace_signing_identity_audit_workspace_idx; + DROP TABLE workspace_signing_identity_audit; + DROP TABLE workspace_signing_identity_provisioning_operations; + DROP TABLE workspace_signing_identities; DROP INDEX workspace_deletion_operations_workspace_recent; DROP TABLE workspace_deletion_operations; CREATE TABLE worker_create_reservations_v52 ( @@ -7969,6 +9415,18 @@ mod tests { version: 53, name: WORKSPACE_DELETION_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 54, + name: WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string(), + }, + WorkspaceSchemaMigrationStep { + version: 55, + name: WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME.to_string(), + }, + WorkspaceSchemaMigrationStep { + version: 56, + name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(), + }, ] ); @@ -7990,6 +9448,15 @@ mod tests { (51, WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME.to_string()), (52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME.to_string()), (53, WORKSPACE_DELETION_MIGRATION_NAME.to_string()), + (54, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME.to_string()), + ( + 55, + WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME.to_string(), + ), + ( + 56, + WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -8001,6 +9468,26 @@ mod tests { )?, 1 ); + assert_eq!( + conn.query_row( + "SELECT state || ':' || authentication_mode + FROM workspace_runtime_bindings + WHERE workspace_id='workspace-a' AND runtime_id='shared'", + [], + |row| row.get::<_, String>(0), + )?, + "verified:legacy_server_issuer" + ); + assert!( + conn.execute( + "UPDATE workspace_runtime_bindings + SET state='configured', authentication_mode='workspace_identity' + WHERE workspace_id='workspace-a' AND runtime_id='shared'", + [], + ) + .is_err(), + "migrated schema must reject Workspace identity mode without key metadata" + ); assert_eq!( conn.query_row( "SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE workspace_id='workspace-a' AND runtime_id='shared' AND jti='jti-1'", @@ -8009,6 +9496,14 @@ mod tests { )?, 1 ); + assert_eq!( + conn.query_row( + "SELECT state FROM workspace_signing_identities WHERE workspace_id='workspace-a'", + [], + |row| row.get::<_, String>(0), + )?, + "pending_provisioning" + ); Ok(()) }) .unwrap(); @@ -8031,7 +9526,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53] + vec![52, 53, 54, 55, 56] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -8039,7 +9534,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 4); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 7); } #[test] @@ -8203,6 +9698,42 @@ mod tests { .unwrap(); } + #[test] + fn schema_v53_signing_identity_migration_rolls_back_on_failure() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + prepare_schema_v52(&path); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + migrate_workspace_deletion_v52_to_v53(&conn).unwrap(); + conn.execute_batch( + "CREATE TABLE workspace_signing_identity_audit (unexpected TEXT NOT NULL);", + ) + .unwrap(); + let before = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name='workspace_signing_identity_audit'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(); + + assert!(migrate_workspace_signing_identity_v53_to_v54(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 53); + assert!(!table_exists(&conn, "workspace_signing_identities").unwrap()); + assert!( + !table_exists(&conn, "workspace_signing_identity_provisioning_operations").unwrap() + ); + let after = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name='workspace_signing_identity_audit'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(); + assert_eq!(after, before); + } + #[test] fn schema_v52_workspace_deletion_migration_rolls_back_on_failure() { let temp = tempfile::tempdir().unwrap(); @@ -8251,6 +9782,12 @@ mod tests { VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'), ('workspace-b', 'owner', 'Workspace B', 'active', '1', '1'); + INSERT INTO workspace_signing_identities( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES + ('workspace-a', 'WK-a', 'ed25519', NULL, NULL, 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'), + ('workspace-b', 'WK-b', 'ed25519', NULL, NULL, 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'); "#, )?; Ok(()) @@ -8265,6 +9802,10 @@ mod tests { public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), binding_revision: 1, + state: WorkspaceRuntimeBindingState::Verified, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -8359,6 +9900,188 @@ mod tests { ); } + #[test] + fn workspace_runtime_verification_is_revision_bound_and_restart_safe() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#" + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at) + VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + INSERT INTO workspace_signing_identities( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) VALUES ('workspace-a', 'WK-a', 'ed25519', 'key', 'sha256:key', + 'workspace-signing/workspace-a/ed25519-v1', 1, 'active', '1', '1', '1'); + "#, + )?; + Ok(()) + }) + .unwrap(); + let runtime_identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + store + .upsert_workspace_runtime_binding( + WorkspaceRuntimeBinding { + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-a".to_string(), + display_name: "runtime-a".to_string(), + base_url: "https://runtime.test".to_string(), + public_key: runtime_identity.public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + state: WorkspaceRuntimeBindingState::Configured, + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("WK-a".to_string()), + workspace_key_generation: Some(1), + created_at: "1".to_string(), + updated_at: "1".to_string(), + revoked_at: None, + }, + false, + ) + .unwrap(); + let persisted = store + .get_workspace_runtime_binding("workspace-a", "runtime-a") + .unwrap() + .unwrap(); + let evidence = WorkspaceRuntimeVerificationEvidence { + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-a".to_string(), + binding_revision: persisted.binding_revision, + workspace_key_id: "WK-a".to_string(), + workspace_identity_revision: 1, + workspace_trust_generation: 1, + runtime_public_key_fingerprint: persisted.public_key_fingerprint.clone(), + runtime_identity_revision: 1, + challenge_id: "challenge-a".to_string(), + state: "verified".to_string(), + last_outcome: "verified".to_string(), + verified_at: Some("2".to_string()), + checked_at: "2".to_string(), + }; + store + .record_workspace_runtime_verification_attempt(&evidence) + .unwrap(); + let verified = store + .complete_workspace_runtime_verification(&evidence) + .unwrap(); + assert_eq!(verified.state, WorkspaceRuntimeBindingState::Verified); + let pending_retry = WorkspaceRuntimeVerificationEvidence { + state: "pending".to_string(), + last_outcome: "challenge_issued".to_string(), + verified_at: None, + checked_at: "3".to_string(), + ..evidence.clone() + }; + store + .record_workspace_runtime_verification_attempt(&pending_retry) + .unwrap(); + let retained = store + .get_workspace_runtime_verification("workspace-a", "runtime-a") + .unwrap() + .unwrap(); + assert_eq!(retained.state, "verified"); + assert_eq!(retained.verified_at.as_deref(), Some("2")); + assert_eq!(retained.last_outcome, "challenge_issued"); + assert!( + !store + .workspace_runtime_verification_matches(&verified, 1, 1) + .unwrap() + ); + store + .complete_workspace_runtime_verification(&evidence) + .unwrap(); + let newer_pending = WorkspaceRuntimeVerificationEvidence { + challenge_id: "challenge-b".to_string(), + state: "pending".to_string(), + last_outcome: "challenge_issued".to_string(), + verified_at: None, + checked_at: "4".to_string(), + ..evidence.clone() + }; + store + .record_workspace_runtime_verification_attempt(&newer_pending) + .unwrap(); + let newer_verified = WorkspaceRuntimeVerificationEvidence { + state: "verified".to_string(), + last_outcome: "verified".to_string(), + verified_at: Some("5".to_string()), + checked_at: "5".to_string(), + ..newer_pending + }; + store + .complete_workspace_runtime_verification(&newer_verified) + .unwrap(); + assert!( + !store + .record_workspace_runtime_verification_outcome_if_current( + &pending_retry, + "failed", + "verification_failed", + "6", + ) + .unwrap() + ); + assert_eq!( + store + .get_workspace_runtime_verification("workspace-a", "runtime-a") + .unwrap(), + Some(newer_verified.clone()) + ); + drop(store); + + let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + assert_eq!( + reopened + .get_workspace_runtime_verification("workspace-a", "runtime-a") + .unwrap(), + Some(newer_verified) + ); + assert_eq!( + reopened + .get_workspace_runtime_binding("workspace-a", "runtime-a") + .unwrap() + .unwrap() + .state, + WorkspaceRuntimeBindingState::Verified + ); + } + + #[test] + fn schema_v55_migrates_runtime_verification_table_atomically() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + "DROP TABLE workspace_runtime_verifications; + DELETE FROM __yoi_schema_migrations; + INSERT INTO __yoi_schema_migrations(version, name) + VALUES (55, 'Workspace Runtime binding state and identity mode');", + )?; + Ok(()) + }) + .unwrap(); + drop(store); + + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 55); + migrate_workspace_runtime_verification_v55_to_v56(&conn).unwrap(); + drop(conn); + let migrated = Connection::open(&path).unwrap(); + configure_sqlite(&migrated).unwrap(); + assert_eq!(current_schema_version(&migrated).unwrap(), 56); + assert!(table_exists(&migrated, "workspace_runtime_verifications").unwrap()); + } + #[test] fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -8385,6 +10108,10 @@ mod tests { public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: WorkspaceRuntimeBindingState::Configured, + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("WK-a".to_string()), + workspace_key_generation: Some(1), created_at: at.to_string(), updated_at: at.to_string(), revoked_at: None, @@ -8399,11 +10126,30 @@ mod tests { .unwrap(); assert_eq!(created, WorkspaceRuntimeBindingMutation::Created); assert_eq!(created_binding.binding_revision, 1); + assert_eq!( + created_binding.state, + WorkspaceRuntimeBindingState::Configured + ); + assert_eq!( + created_binding.authentication_mode, + WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + ); + assert_eq!(created_binding.workspace_key_id.as_deref(), Some("WK-a")); + assert_eq!(created_binding.workspace_key_generation, Some(1)); let (replayed, replayed_binding) = store .put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner") .unwrap(); assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged); assert_eq!(replayed_binding.binding_revision, 1); + let mut mismatched_replay = binding(replayed_binding.public_key.clone(), "2"); + mismatched_replay.base_url = "https://different.runtime.test".to_string(); + assert!(matches!( + store.put_workspace_runtime_binding_key(mismatched_replay, None, "owner"), + Err(Error::RuntimeBindingRevisionConflict { + expected: None, + actual: Some(1) + }) + )); let stale = store .put_workspace_runtime_binding_key( @@ -8434,6 +10180,7 @@ mod tests { assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked); assert_eq!(revoked_binding.binding_revision, 3); assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); + assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked); let (reactivated, reactivated_binding) = store .put_workspace_runtime_binding_key( binding(second.public_key.clone(), "5"), @@ -8443,6 +10190,10 @@ mod tests { .unwrap(); assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated); assert_eq!(reactivated_binding.binding_revision, 4); + assert_eq!( + reactivated_binding.state, + WorkspaceRuntimeBindingState::Configured + ); let mut duplicate = binding(second.public_key, "6"); duplicate.runtime_id = "runtime-b".to_string(); @@ -8493,6 +10244,10 @@ mod tests { public_key, public_key_fingerprint: String::new(), binding_revision: 1, + state: WorkspaceRuntimeBindingState::Verified, + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -9647,13 +11402,13 @@ INSERT INTO worker_registry ( let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (54, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (57, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 54 is newer"), "{error}"); + assert!(error.contains("schema version 57 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -9695,6 +11450,14 @@ INSERT INTO accounts (account_id, kind, handle, display_name, created_at, update VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', '2026-01-01', '2026-01-01'); INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, created_at, updated_at) VALUES ('workspace-a', 'owner-account', 'A', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at +) VALUES ( + 'workspace-a', 'WK-a', 'ed25519', NULL, NULL, + 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', + '2026-01-01', NULL, '2026-01-01' +); INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit @@ -9717,6 +11480,14 @@ DELETE FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, created_at, updated_at) VALUES ('workspace-b', 'owner-account', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at +) VALUES ( + 'workspace-b', 'WK-b', 'ed25519', NULL, NULL, + 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', + '2026-01-01', NULL, '2026-01-01' +); INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit @@ -9800,13 +11571,26 @@ INSERT INTO worker_registry ( updated_at: "1".to_string(), }; + let signing_identity = WorkspaceSigningIdentityActivation { + workspace_id: workspace.workspace_id.clone(), + key_id: "WK-store-test".to_string(), + public_key: "test-public-key".to_string(), + public_key_fingerprint: "sha256:test-public-key".to_string(), + private_material_ref: "workspace-signing/store-test/ed25519-v1".to_string(), + revision: 1, + provisioned_at: "1".to_string(), + }; let error = store - .create_workspace_bootstrap(&WorkspaceBootstrapRecord { - operation_key: "invalid-key".to_string(), - request_fingerprint: "sha256:invalid-key".to_string(), - workspace: workspace.clone(), - repository, - }) + .create_workspace_bootstrap( + &WorkspaceBootstrapRecord { + operation_key: "invalid-key".to_string(), + request_fingerprint: "sha256:invalid-key".to_string(), + workspace: workspace.clone(), + repository, + }, + &signing_identity, + "identity-store-test", + ) .unwrap_err() .to_string(); @@ -9843,12 +11627,34 @@ INSERT INTO worker_registry ( workspace, repository: valid_repository, }; - assert!(!store.create_workspace_bootstrap(&first).unwrap().replayed); + store + .reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: "identity-store-test".to_string(), + request_fingerprint: "sha256:create-workspace".to_string(), + operation_kind: "workspace_create".to_string(), + workspace_id: first.workspace.workspace_id.clone(), + key_id: signing_identity.key_id.clone(), + private_material_ref: signing_identity.private_material_ref.clone(), + revision: signing_identity.revision, + actor_account_id: first.workspace.owner_account_id.clone(), + state: "pending".to_string(), + created_at: "1".to_string(), + completed_at: None, + }, + ) + .unwrap(); + assert!( + !store + .create_workspace_bootstrap(&first, &signing_identity, "identity-store-test") + .unwrap() + .replayed + ); let mut duplicate = first; duplicate.operation_key = "duplicate-workspace".to_string(); duplicate.repository.repository_id = Uuid::now_v7().to_string(); let error = store - .create_workspace_bootstrap(&duplicate) + .create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test") .unwrap_err() .to_string(); assert!( diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 308b116a..94c2a4e2 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -10,7 +10,6 @@ use worker_runtime::auth::{ }; use worker_runtime::worker_source::InProcessWorkerMutationProof; -use crate::hosts::RemoteRuntimeConfig; use crate::server::{ServerConfig, WorkspaceApi}; use crate::store::ControlPlaneStore; @@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store( ) -> Result { let unverified = decode_runtime_request_source_claims(proof) .map_err(|_| WorkerMutationSourceProofError::Invalid)?; - let audience = remote_audience(config, &unverified.iss, workspace_id)?; + let audience = remote_audience(config, workspace_id)?; let trusted = store .get_workspace_runtime_binding(workspace_id, &unverified.iss) .await @@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with( PresentedWorkerMutationSourceProof::Remote(token) => { let unverified = decode_worker_mutation_source_claims(token) .map_err(|_| WorkerMutationSourceProofError::Invalid)?; - let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?; + let audience = remote_audience(config, &config.workspace_id)?; let trusted = store .get_workspace_runtime_binding(&config.workspace_id, &unverified.iss) .await @@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher } fn remote_audience<'a>( - config: &'a crate::server::ServerConfig, - runtime_id: &str, + config: &'a ServerConfig, workspace_id: &str, -) -> Result, WorkerMutationSourceProofError> { - if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID { - return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}"))); - } +) -> Result<&'a str, WorkerMutationSourceProofError> { config - .remote_runtime_sources - .iter() - .find(|runtime| runtime.runtime_id == runtime_id) - .and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref()) - .map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str())) - .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust) + .backend_base_url + .as_deref() + .map(str::trim) + .filter(|audience| !audience.is_empty()) + .ok_or_else(|| { + WorkerMutationSourceProofError::Authority(format!( + "Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification" + )) + }) } fn validate_in_process_claims( diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index a606dde3..f0600f72 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger use crate::store::{ ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, }; +use crate::workspace_signing_identity::{ + WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore, +}; use crate::{Error, Result}; const MAX_DISPLAY_NAME_BYTES: usize = 200; @@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult { #[derive(Clone)] pub struct WorkspaceCatalogService { store: Arc, + signing_identities: WorkspaceSigningIdentityService, } impl WorkspaceCatalogService { - pub fn new(store: Arc) -> Self { - Self { store } + pub fn new( + store: Arc, + signing_materials: Arc, + ) -> Self { + Self { + signing_identities: WorkspaceSigningIdentityService::new( + store.clone(), + signing_materials, + ), + store, + } } pub fn is_empty(&self) -> Result { @@ -118,7 +131,7 @@ impl WorkspaceCatalogService { .map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string())) }) .transpose()?; - let workspace_id = requested_workspace_id + let proposed_workspace_id = requested_workspace_id .clone() .unwrap_or_else(|| Uuid::now_v7().to_string()); let fingerprint = workspace_create_fingerprint( @@ -130,9 +143,16 @@ impl WorkspaceCatalogService { &default_ref, ); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let result = self - .store - .create_workspace_bootstrap(&WorkspaceBootstrapRecord { + let (signing_identity, identity_provisioning_operation_key) = + self.signing_identities.prepare_workspace_creation( + &operation_key, + &fingerprint, + &proposed_workspace_id, + &owner_account_id, + )?; + let workspace_id = signing_identity.workspace_id.clone(); + let result = self.store.create_workspace_bootstrap( + &WorkspaceBootstrapRecord { operation_key, request_fingerprint: fingerprint.clone(), workspace: WorkspaceRecord { @@ -158,7 +178,10 @@ impl WorkspaceCatalogService { created_at: now.clone(), updated_at: now, }, - })?; + }, + &signing_identity, + &identity_provisioning_operation_key, + )?; Ok(WorkspaceCreateResult { workspace: result.workspace, repository: result.repository, @@ -214,10 +237,45 @@ fn workspace_create_fingerprint( #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use super::*; use crate::store::{AccountRecord, SqliteWorkspaceStore}; + use crate::workspace_signing_identity::{ + InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore, + WorkspaceSigningPrivateMaterial, identity_error, + }; use workspace_api::RepositorySourceKind; + struct FailFirstMaterialWrite { + inner: Arc, + fail: AtomicBool, + } + + impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite { + fn load(&self, material_ref: &str) -> Result> { + self.inner.load(material_ref) + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + if self.fail.swap(false, Ordering::SeqCst) { + return Err(identity_error( + "workspace_signing_identity_material_io_failed", + "injected private material write failure", + )); + } + self.inner.put_if_absent(material_ref, material) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + self.inner.delete(material_ref) + } + } + fn git_repository() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap(); @@ -243,7 +301,12 @@ mod tests { #[tokio::test] async fn create_is_atomic_and_exact_retries_converge() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); - let service = WorkspaceCatalogService::new(store.clone()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository = git_repository(); let request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), @@ -268,6 +331,14 @@ mod tests { replayed.workspace.workspace_id ); assert_eq!(store.list_workspaces().unwrap().len(), 1); + let signing_identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .expect("new Workspace signing identity"); + assert_eq!(signing_identity.state, "active"); + assert_eq!(signing_identity.algorithm, "ed25519"); + assert!(signing_identity.public_key.is_some()); + assert!(signing_identity.public_key_fingerprint.is_some()); assert_eq!( store .list_repositories(&created.workspace.workspace_id) @@ -283,11 +354,137 @@ mod tests { ); } + #[tokio::test] + async fn create_recovers_same_reserved_identity_after_material_write_failure() { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let owner_account_id = owner_account(store.as_ref()); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FailFirstMaterialWrite { + inner: materials.clone(), + fail: AtomicBool::new(true), + }), + ); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "material-failure".to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: "main".to_string(), + default_ref: None, + }, + }; + + assert!( + service + .create(request.clone(), owner_account_id.clone()) + .is_err() + ); + assert!(store.list_workspaces().unwrap().is_empty()); + let reserved_key = store + .with_conn(|conn| { + conn.query_row( + "SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(Error::from) + }) + .unwrap(); + + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceCatalogService::new(store.clone(), materials); + let created = restarted.create(request, owner_account_id).unwrap(); + let identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .unwrap(); + assert_eq!(identity.key_id, reserved_key); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + } + + #[tokio::test] + async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let owner_account_id = owner_account(store.as_ref()); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceCatalogService::new(store.clone(), materials.clone()); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "db-failure".to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: "main".to_string(), + default_ref: None, + }, + }; + store + .with_conn(|conn| { + conn.execute_batch( + r#"CREATE TRIGGER fail_workspace_create_identity_audit + BEFORE INSERT ON workspace_signing_identity_audit + BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#, + )?; + Ok(()) + }) + .unwrap(); + + assert!( + service + .create(request.clone(), owner_account_id.clone()) + .is_err() + ); + assert!(store.list_workspaces().unwrap().is_empty()); + let (reserved_key, material_ref) = store + .with_conn(|conn| { + conn.query_row( + "SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'", + [], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .map_err(Error::from) + }) + .unwrap(); + assert!(materials.load(&material_ref).unwrap().is_some()); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?; + Ok(()) + }) + .unwrap(); + + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceCatalogService::new(store.clone(), materials); + let created = restarted.create(request, owner_account_id).unwrap(); + let identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .unwrap(); + assert_eq!(identity.key_id, reserved_key); + assert_eq!(identity.state, "active"); + } + #[tokio::test] async fn idempotency_key_reuse_with_different_payload_is_rejected() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let owner_account_id = owner_account(store.as_ref()); - let service = WorkspaceCatalogService::new(store); + let service = WorkspaceCatalogService::new( + store, + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository = git_repository(); let mut request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), @@ -338,7 +535,12 @@ mod tests { updated_at: "2026-07-03T00:00:00Z".to_string(), }) .unwrap(); - let service = WorkspaceCatalogService::new(store); + let service = WorkspaceCatalogService::new( + store, + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository = git_repository(); let error = service .create( @@ -373,7 +575,12 @@ mod tests { updated_at: "2026-07-03T00:00:00Z".to_string(), }) .unwrap(); - let service = WorkspaceCatalogService::new(store); + let service = WorkspaceCatalogService::new( + store, + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let repository_a = git_repository(); let repository_b = git_repository(); let created_a = service @@ -425,7 +632,12 @@ mod tests { fn remote_repository_creation_persists_typed_source_without_auth_metadata() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let owner_account_id = owner_account(store.as_ref()); - let service = WorkspaceCatalogService::new(store.clone()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); let result = service .create( WorkspaceCreateRequest { diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 1382edcf..5dda2f84 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -80,6 +80,10 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "workspace_resource_keys", "workspace_runtime_binding_audit", "workspace_runtime_bindings", + "workspace_runtime_verifications", + "workspace_signing_identities", + "workspace_signing_identity_audit", + "workspace_signing_identity_provisioning_operations", "workspace_worker_retention_policies", "workspace_worker_retention_policy_revisions", ]; diff --git a/crates/workspace-server/src/workspace_signing_identity.rs b/crates/workspace-server/src/workspace_signing_identity.rs new file mode 100644 index 00000000..c57169b6 --- /dev/null +++ b/crates/workspace-server/src/workspace_signing_identity.rs @@ -0,0 +1,880 @@ +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use std::sync::Arc; + +use chrono::{SecondsFormat, Utc}; +use ring::signature::KeyPair; +use serde::{Deserialize, Serialize}; +use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key}; +use worker_runtime::workspace_issuer::{ + WorkspaceCapabilityClaims, WorkspaceCapabilityVerificationError, + assemble_workspace_capability_token, workspace_capability_signing_input, +}; +use zeroize::Zeroize; + +use crate::store::{ + ControlPlaneStore, WorkspaceSigningIdentityActivation, + WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord, +}; +use crate::{Error, Result}; + +pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; +pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1; +const MATERIAL_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSigningPrivateMaterial { + version: u32, + workspace_id: String, + key_id: String, + revision: u64, + private_key: String, +} + +impl fmt::Debug for WorkspaceSigningPrivateMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WorkspaceSigningPrivateMaterial") + .field("version", &self.version) + .field("workspace_id", &self.workspace_id) + .field("key_id", &self.key_id) + .field("revision", &self.revision) + .field("private_key", &"[REDACTED]") + .finish() + } +} + +impl Drop for WorkspaceSigningPrivateMaterial { + fn drop(&mut self) { + self.private_key.zeroize(); + } +} + +impl WorkspaceSigningPrivateMaterial { + pub fn generate(workspace_id: &str, key_id: &str) -> Result { + let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| { + identity_error( + "workspace_signing_identity_generation_failed", + format!("failed to generate Workspace signing identity: {error}"), + ) + })?; + Ok(Self { + version: MATERIAL_SCHEMA_VERSION, + workspace_id: workspace_id.to_string(), + key_id: key_id.to_string(), + revision: WORKSPACE_SIGNING_IDENTITY_REVISION, + private_key: material.private_key, + }) + } + + fn signing_key( + &self, + expected_workspace_id: &str, + expected_key_id: &str, + expected_revision: u64, + ) -> Result { + if self.version != MATERIAL_SCHEMA_VERSION + || self.workspace_id != expected_workspace_id + || self.key_id != expected_key_id + || self.revision != expected_revision + { + return Err(identity_error( + "workspace_signing_identity_material_mismatch", + "Workspace signing private material does not match its persisted metadata", + )); + } + RuntimeIdentityMaterial { + identity_id: self.key_id.clone(), + public_key: String::new(), + private_key: self.private_key.clone(), + } + .signing_key() + .map_err(|_| { + identity_error( + "workspace_signing_identity_material_corrupt", + "Workspace signing private material is corrupt", + ) + }) + } + + pub fn validate_and_public_key( + &self, + expected_workspace_id: &str, + expected_key_id: &str, + expected_revision: u64, + ) -> Result { + let signing_key = + self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?; + Ok(encode_public_key(signing_key.public_key().as_ref())) + } +} + +pub trait WorkspaceSigningMaterialStore: Send + Sync { + fn load(&self, material_ref: &str) -> Result>; + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result; + fn delete(&self, material_ref: &str) -> Result<()>; +} + +#[derive(Clone)] +pub struct WorkspaceSigningIdentityService { + store: Arc, + materials: Arc, +} + +impl WorkspaceSigningIdentityService { + pub fn new( + store: Arc, + materials: Arc, + ) -> Self { + Self { store, materials } + } + + pub fn prepare_workspace_creation( + &self, + workspace_create_operation_key: &str, + request_fingerprint: &str, + proposed_workspace_id: &str, + actor_account_id: &str, + ) -> Result<(WorkspaceSigningIdentityActivation, String)> { + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple()); + let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1"); + let operation_key = format!("workspace-create:{workspace_create_operation_key}"); + let operation = self.store.reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: operation_key.clone(), + request_fingerprint: request_fingerprint.to_string(), + operation_kind: "workspace_create".to_string(), + workspace_id: proposed_workspace_id.to_string(), + key_id: proposed_key_id, + private_material_ref: proposed_material_ref, + revision: WORKSPACE_SIGNING_IDENTITY_REVISION, + actor_account_id: actor_account_id.to_string(), + state: "pending".to_string(), + created_at: now, + completed_at: None, + }, + )?; + let activation = self.prepare_material(&operation)?; + Ok((activation, operation.operation_key)) + } + + pub fn provision_existing( + &self, + workspace_id: &str, + actor_account_id: &str, + ) -> Result { + let identity = self + .store + .get_workspace_signing_identity(workspace_id)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_metadata_missing", + "Workspace signing identity metadata is missing", + ) + })?; + if identity.state == "active" { + self.validate_active_material(&identity)?; + return Ok(identity); + } + if identity.state != "pending_provisioning" { + return Err(identity_error( + "workspace_signing_identity_state_invalid", + "Workspace signing identity state is invalid", + )); + } + let operation_key = format!( + "existing-workspace:{workspace_id}:revision-{}", + identity.revision + ); + let request_fingerprint = + provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision); + let operation = self.store.reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: operation_key.clone(), + request_fingerprint, + operation_kind: "existing_workspace".to_string(), + workspace_id: workspace_id.to_string(), + key_id: identity.key_id.clone(), + private_material_ref: identity.private_material_ref.clone(), + revision: identity.revision, + actor_account_id: actor_account_id.to_string(), + state: "pending".to_string(), + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + completed_at: None, + }, + )?; + let activation = self.prepare_material(&operation)?; + self.store.activate_workspace_signing_identity( + &activation, + &operation.operation_key, + &operation.actor_account_id, + ) + } + + pub fn get_validated(&self, workspace_id: &str) -> Result { + let identity = self + .store + .get_workspace_signing_identity(workspace_id)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_metadata_missing", + "Workspace signing identity metadata is missing", + ) + })?; + if identity.state == "active" { + self.validate_active_material(&identity)?; + } + Ok(identity) + } + + pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result> { + let identity = self.get_validated(workspace_id)?; + if identity.state != "active" { + return Err(identity_error( + "workspace_signing_identity_not_provisioned", + "Workspace signing identity is not provisioned", + )); + } + let material = self + .materials + .load(&identity.private_material_ref)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_missing", + "Workspace signing private material is missing", + ) + })?; + let signing_key = + material.signing_key(workspace_id, &identity.key_id, identity.revision)?; + Ok(signing_key.sign(payload).as_ref().to_vec()) + } + + pub fn issue_workspace_capability( + &self, + workspace_id: &str, + claims: &WorkspaceCapabilityClaims, + ) -> Result { + let input = workspace_capability_signing_input(claims).map_err(capability_error)?; + let signature = self.sign(workspace_id, input.bytes())?; + assemble_workspace_capability_token(input, &signature).map_err(capability_error) + } + + pub fn delete_material(&self, workspace_id: &str) -> Result<()> { + if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? { + self.materials.delete(&identity.private_material_ref)?; + } + Ok(()) + } + + fn prepare_material( + &self, + operation: &WorkspaceSigningIdentityProvisioningOperation, + ) -> Result { + let material = match self.materials.load(&operation.private_material_ref)? { + Some(material) => material, + None => { + let generated = WorkspaceSigningPrivateMaterial::generate( + &operation.workspace_id, + &operation.key_id, + )?; + self.materials + .put_if_absent(&operation.private_material_ref, &generated)? + } + }; + let public_key = material.validate_and_public_key( + &operation.workspace_id, + &operation.key_id, + operation.revision, + )?; + let public_key_fingerprint = public_key_fingerprint(&public_key)?; + Ok(WorkspaceSigningIdentityActivation { + workspace_id: operation.workspace_id.clone(), + key_id: operation.key_id.clone(), + public_key, + public_key_fingerprint, + private_material_ref: operation.private_material_ref.clone(), + revision: operation.revision, + provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + }) + } + + fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> { + let material = self + .materials + .load(&identity.private_material_ref)? + .ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_missing", + "Workspace signing private material is missing", + ) + })?; + let public_key = material.validate_and_public_key( + &identity.workspace_id, + &identity.key_id, + identity.revision, + )?; + let fingerprint = public_key_fingerprint(&public_key)?; + if identity.public_key.as_deref() != Some(public_key.as_str()) + || identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str()) + { + return Err(identity_error( + "workspace_signing_identity_material_mismatch", + "Workspace signing private material does not match public metadata", + )); + } + Ok(()) + } +} + +fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(workspace_id.as_bytes()); + hasher.update([0]); + hasher.update(key_id.as_bytes()); + hasher.update([0]); + hasher.update(revision.to_be_bytes()); + format!("sha256:{}", hex_lower(&hasher.finalize())) +} + +#[derive(Default)] +pub struct InMemoryWorkspaceSigningMaterialStore { + materials: std::sync::Mutex>, +} + +impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore { + fn load(&self, material_ref: &str) -> Result> { + Ok(self + .materials + .lock() + .expect("identity material store lock") + .get(material_ref) + .cloned()) + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + let mut materials = self.materials.lock().expect("identity material store lock"); + Ok(materials + .entry(material_ref.to_string()) + .or_insert_with(|| material.clone()) + .clone()) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + self.materials + .lock() + .expect("identity material store lock") + .remove(material_ref); + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct FsWorkspaceSigningMaterialStore { + root: PathBuf, +} + +impl FsWorkspaceSigningMaterialStore { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + fn material_path(&self, material_ref: &str) -> Result { + let relative = Path::new(material_ref); + if relative.as_os_str().is_empty() + || relative.is_absolute() + || relative.components().any(|component| { + !matches!(component, Component::Normal(_)) + || component.as_os_str().to_string_lossy().starts_with('.') + }) + { + return Err(identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material reference is invalid", + )); + } + Ok(self.root.join(relative).with_extension("json")) + } +} + +impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore { + fn load(&self, material_ref: &str) -> Result> { + let path = self.material_path(material_ref)?; + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(material_io_error("read", error)), + }; + serde_json::from_slice(&bytes).map(Some).map_err(|_| { + identity_error( + "workspace_signing_identity_material_corrupt", + "Workspace signing private material is corrupt", + ) + }) + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + let path = self.material_path(material_ref)?; + let parent = path.parent().ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material reference has no parent", + ) + })?; + ensure_private_tree(&self.root, parent)?; + + let mut bytes = serde_json::to_vec(material).map_err(|_| { + identity_error( + "workspace_signing_identity_material_encode_failed", + "Workspace signing private material could not be encoded", + ) + })?; + let temporary = parent.join(format!( + ".workspace-signing-{}.tmp", + uuid::Uuid::now_v7().simple() + )); + let write_result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| material_io_error("create", error))?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| material_io_error("write", error))?; + match fs::hard_link(&temporary, &path) { + Ok(()) => sync_directory(parent), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(material_io_error("publish", error)), + } + })(); + bytes.zeroize(); + let cleanup_result = match fs::remove_file(&temporary) { + Ok(()) => sync_directory(parent), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(material_io_error("remove temporary", error)), + }; + write_result?; + cleanup_result?; + self.load(material_ref)?.ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_missing", + "Workspace signing private material is missing after publication", + ) + }) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + let path = self.material_path(material_ref)?; + match fs::remove_file(&path) { + Ok(()) => { + let parent = path.parent().ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material reference has no parent", + ) + })?; + sync_directory(parent) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(material_io_error("delete", error)), + } + } +} + +fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> { + ensure_private_directory(root)?; + if let Some(parent) = root.parent() { + sync_directory(parent)?; + } + let relative = leaf.strip_prefix(root).map_err(|_| { + identity_error( + "workspace_signing_identity_material_ref_invalid", + "Workspace signing private material path escapes its authority root", + ) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let parent = current.clone(); + current.push(component); + ensure_private_directory(¤t)?; + sync_directory(&parent)?; + } + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<()> { + std::fs::File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|error| material_io_error("synchronize directory", error)) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<()> { + Err(identity_error( + "workspace_signing_identity_durable_publish_unsupported", + "Workspace signing private material durable publication is unsupported on this platform", + )) +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| material_io_error("set directory permissions", error))?; + } + Ok(()) +} + +pub fn workspace_signing_material_root(database_path: &Path) -> PathBuf { + database_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("workspace-signing-identities") +} + +pub fn public_key_fingerprint(public_key: &str) -> Result { + let bytes = worker_runtime::auth::decode_public_key(public_key).map_err(|_| { + identity_error( + "workspace_signing_identity_public_key_invalid", + "Workspace signing public key is invalid", + ) + })?; + use sha2::{Digest, Sha256}; + Ok(format!("sha256:{}", hex_lower(&Sha256::digest(bytes)))) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn material_io_error(action: &str, error: std::io::Error) -> Error { + identity_error( + "workspace_signing_identity_material_io_failed", + format!("failed to {action} Workspace signing private material: {error}"), + ) +} + +fn capability_error(error: WorkspaceCapabilityVerificationError) -> Error { + identity_error( + "workspace_capability_issuance_failed", + format!("failed to issue Workspace capability: {error}"), + ) +} + +pub fn identity_error(code: impl Into, message: impl Into) -> Error { + Error::WorkspaceSigningIdentity { + code: code.into(), + message: message.into(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + + struct FailFirstMaterialWrite { + inner: Arc, + fail: AtomicBool, + } + + impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite { + fn load(&self, material_ref: &str) -> Result> { + self.inner.load(material_ref) + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + if self.fail.swap(false, Ordering::SeqCst) { + return Err(identity_error( + "workspace_signing_identity_material_io_failed", + "injected private material write failure", + )); + } + self.inner.put_if_absent(material_ref, material) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + self.inner.delete(material_ref) + } + } + + #[tokio::test] + async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() { + use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord}; + + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + store + .upsert_account(&AccountRecord { + account_id: "account-1".to_string(), + kind: "user".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-1".to_string(), + owner_account_id: "account-1".to_string(), + display_name: "Workspace".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let failing_service = WorkspaceSigningIdentityService::new( + store.clone(), + Arc::new(FailFirstMaterialWrite { + inner: materials.clone(), + fail: AtomicBool::new(true), + }), + ); + assert_eq!( + failing_service.get_validated("workspace-1").unwrap().state, + "pending_provisioning" + ); + let error = failing_service + .provision_existing("workspace-1", "account-1") + .unwrap_err(); + assert!(matches!( + error, + Error::WorkspaceSigningIdentity { ref code, .. } + if code == "workspace_signing_identity_material_io_failed" + )); + assert_eq!( + store + .get_workspace_signing_identity("workspace-1") + .unwrap() + .unwrap() + .state, + "pending_provisioning" + ); + + drop(failing_service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone()); + let provisioned = service + .provision_existing("workspace-1", "account-1") + .unwrap(); + assert_eq!(provisioned.state, "active"); + assert!(provisioned.public_key.is_some()); + let payload = b"Workspace authority proof"; + let signature = service.sign("workspace-1", payload).unwrap(); + let public_key = + worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap()) + .unwrap(); + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key) + .verify(payload, &signature) + .unwrap(); + assert_eq!( + service + .provision_existing("workspace-1", "account-1") + .unwrap(), + provisioned + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE workspace_id = 'workspace-1'", + [], + |row| row.get::<_, i64>(0), + )?, + 1 + ); + Ok(()) + }) + .unwrap(); + + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-2".to_string(), + owner_account_id: "account-1".to_string(), + display_name: "Workspace 2".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let pending = store + .get_workspace_signing_identity("workspace-2") + .unwrap() + .unwrap(); + let operation = store + .reserve_workspace_signing_identity_provisioning( + &WorkspaceSigningIdentityProvisioningOperation { + operation_key: "existing-workspace:workspace-2:revision-1".to_string(), + request_fingerprint: provisioning_fingerprint( + "workspace-2", + &pending.key_id, + pending.revision, + ), + operation_kind: "existing_workspace".to_string(), + workspace_id: "workspace-2".to_string(), + key_id: pending.key_id.clone(), + private_material_ref: pending.private_material_ref.clone(), + revision: pending.revision, + actor_account_id: "account-1".to_string(), + state: "pending".to_string(), + created_at: "1".to_string(), + completed_at: None, + }, + ) + .unwrap(); + let activation = service.prepare_material(&operation).unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#"CREATE TRIGGER fail_workspace_signing_identity_audit + BEFORE INSERT ON workspace_signing_identity_audit + BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#, + )?; + Ok(()) + }) + .unwrap(); + assert!( + store + .activate_workspace_signing_identity( + &activation, + &operation.operation_key, + "account-1", + ) + .is_err() + ); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?; + Ok(()) + }) + .unwrap(); + assert_eq!( + store + .get_workspace_signing_identity("workspace-2") + .unwrap() + .unwrap() + .state, + "pending_provisioning" + ); + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone()); + let recovered = restarted + .provision_existing("workspace-2", "account-1") + .unwrap(); + assert_eq!(recovered.key_id, activation.key_id); + assert_eq!( + recovered.public_key_fingerprint.as_deref(), + Some(activation.public_key_fingerprint.as_str()) + ); + + materials.delete(&provisioned.private_material_ref).unwrap(); + let error = restarted.get_validated("workspace-1").unwrap_err(); + assert!(matches!( + error, + Error::WorkspaceSigningIdentity { ref code, .. } + if code == "workspace_signing_identity_material_missing" + )); + } + + #[test] + fn file_store_round_trips_private_material_without_overwrite() { + let temp = tempfile::tempdir().unwrap(); + let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities")); + let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap(); + let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(temp.path().join("identities")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(store.material_path("ws-1/ed25519-v1").unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + assert_eq!( + persisted + .validate_and_public_key("ws-1", "WK-1", 1) + .unwrap(), + first_public + ); + + let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap(); + assert_eq!( + persisted + .validate_and_public_key("ws-1", "WK-1", 1) + .unwrap(), + first_public + ); + } + + #[test] + fn corrupt_and_cross_workspace_material_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities")); + let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + store.put_if_absent("ws-1/ed25519-v1", &material).unwrap(); + let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap(); + assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err()); + + fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap(); + assert!(store.load("ws-1/ed25519-v1").is_err()); + } +} diff --git a/docs/design/workspace-runtime-docker.md b/docs/design/workspace-runtime-docker.md index 2f8a2d5d..c5ad5003 100644 --- a/docs/design/workspace-runtime-docker.md +++ b/docs/design/workspace-runtime-docker.md @@ -24,6 +24,8 @@ Responsibilities are split as follows: The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records. +Remote Runtime authentication follows the same Workspace boundary. The Server signs each Runtime request with the target Workspace signing identity, and the Runtime verifies it against the installed Workspace issuer bundle. Runtime-to-Server source proof is signed by the Runtime identity and uses the bundle's Backend URL as audience. Server-global signing identities, Runtime-side global Server trust, and static bearer fallback are not Remote Workspace authority. Provisioning and rotation are described in [Workspace ↔ Runtime authentication](../development/server-runtime-auth.md). + ## Docker image layout Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile. diff --git a/docs/development/dogfooding.md b/docs/development/dogfooding.md index 6718950d..f71ad195 100644 --- a/docs/development/dogfooding.md +++ b/docs/development/dogfooding.md @@ -4,35 +4,15 @@ This repository is developed with Yoi itself. Dogfooding is valuable because it ## Pre-restart gate -Never use the live dogfood Server or Runtime as the first startup test for a new -binary. A dogfood restart is allowed only after this sequence succeeds: +Never use the live dogfood Server or Runtime as the first startup test for a new binary. A dogfood restart is allowed only after this sequence succeeds: -1. Build the production entrypoints: - `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`. -2. Run the focused and dependent tests for the changed contracts, followed by - `cargo fmt --all -- --check` and `git diff --check HEAD`. -3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process. -4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs; - do not restart dogfood until the cause is fixed and the smoke passes. -5. Have an external supervisor or operator restart Server and Runtime. A Worker - hosted by the target Runtime must never terminate its own Runtime. -6. Verify post-restart readiness through the Workspace Runtime projection and a - real restored Worker operation before treating the environment as healthy. +1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`. +2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`. +3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified. +4. Have an external supervisor or operator restart Server and Runtime at the same generation. A Worker hosted by the target Runtime must never terminate its own Runtime. +5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy. -The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using -separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository, -Server database, Runtime fs store, identity/trust material, and non-dogfood -ports. It fails if either port is already occupied, if state escapes the -temporary root, if a process exits unexpectedly, if Runtime readiness is not -visible through Server, or if startup logs contain a panic, migration collision, -or Worker execution restore failure. It also proves that a listening Server -without its configured Runtime is not readiness and restarts the isolated -Runtime once to exercise persistence reopen. - -Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`, -`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build -or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful -artifacts. Failed artifacts are retained automatically. +The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly. ## What to record @@ -45,6 +25,8 @@ A report is useful when it explains: - what design boundary was missing - what evidence was observed +For a Remote Runtime rollout, also record the source commit, binary generation, Server schema version, Runtime binding revision, Workspace key generation, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors. + ## Runtime command caveat After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides. diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index 5439de6d..4dca68e7 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -1,266 +1,76 @@ -# Server / Runtime manual auth setup +# Workspace ↔ Runtime 認証 -Workspace Server and Worker Runtime authenticate remote Runtime control traffic with manually exchanged Ed25519 public keys and short-lived Server-signed capability tokens. +Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。 +Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。 -This is a non-interactive bootstrap flow. Commands fail when required flags are missing, and existing identity/trust records are not overwritten unless `--replace` is passed explicitly. +## Authority -## Authority boundary +- Server DB は Workspace ごとの signing identity と Runtime binding を保持する。 +- Runtime は `trust-workspace` で受理した `WorkspaceIssuerAuthorizationBundle` を保持する。 +- bundle は `workspace_id`、Workspace key id/generation、Workspace public key、Backend URL、許可された Runtime identity を固定する。 +- Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。 +- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key generation、expiry、JTI を検証する。 +- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。 +- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。 -- Workspace Server is the workspace control plane. It owns trusted Runtime records in the Server DB and signs per-request Runtime capability tokens. -- Runtime owns Worker execution. It does not own a workspace registry or workspace list. -- Runtime API paths remain worker-centric; workspace scope is carried in the signed auth context and enforced by Runtime-side authorization/filtering code. -- Browser/Web clients should talk to Workspace Server, not directly to Runtime. +旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。 -## Identifiers used in examples +## Provisioning -Replace these values for the deployment: +1. Runtime identity を初期化する。 -```text -SERVER_ID=server-main -RUNTIME_ID=runtime-main -RUNTIME_BASE_URL=http://127.0.0.1:38800 -``` + ```sh + yoi-runtime identity init --runtime-id + yoi-runtime identity show + ``` -`SERVER_ID` is the issuer id in Server-signed tokens. `RUNTIME_ID` is the token audience and must match the Runtime identity. +2. Workspace owner が Settings → Runtimes から Runtime public bundle と endpoint を登録する。 +3. Server が Workspace issuer bundle と challenge を発行する。 +4. operator が bundle を Runtime に追加する。 -## 1. Create and show the Server identity + ```sh + yoi-runtime trust-workspace add --bundle + yoi-runtime trust-workspace show --workspace-id + ``` -From the Workspace Server host: +5. Runtime が challenge proof を生成し、Workspace owner が Server に submit する。 +6. Server が verified binding を commit した後、通常の Workspace-signed request が利用可能になる。 -```bash -yoi-server identity init --server-id server-main -``` +同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。 -Show the public identity and copy the `public_key` value: +## Runtime auth file -```bash -yoi-server identity show --json -``` +`runtime-auth.toml` は Runtime identity と Workspace issuer records のみを authority とする。 +旧 Server trust entry は読み飛ばされ、以後の identity / `trust-workspace` 更新時に書き戻されない。旧 entry を残しても認証には使用されない。 -The Server private identity is stored in the Yoi data directory under the Server data root, currently: +`trust-workspace` の file store は次を fail closed で検証する。 -```text -/server/identity.toml -``` +- 最大 8 MiB +- 最大 4,096 records +- exact Workspace / Runtime identity +- key id/generation と public key fingerprint +- normalized Backend URL +- replace 時の expected current generation +- list は `offset` / `limit` 必須で、1 page 最大 100 records -On Unix this file is written with `0600` permissions. Do not copy the private key to Runtime or commit it to the repository. +## Local token -## 2. Create and show the Runtime identity +`--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。 -From the Runtime host, using the same Runtime storage flags that the Runtime server process will use: +## Rotation と失効 -```bash -yoi-runtime identity init --runtime-id runtime-main -``` +Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。 -Show the public identity and copy the `public_key` value: +Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、binding revision、Workspace key generation の変更を検知した場合、stale client を利用しない。 -```bash -yoi-runtime identity show --json -``` +## 運用確認 -By default, Runtime auth state is stored at: +Remote Runtime を有効化した後は次を確認する。 -```text -/runtime/auth.toml -``` +1. `yoi-runtime trust-workspace show --workspace-id ` が期待する bundle を表示する。 +2. Workspace Settings の Runtime binding が `verified` で、現在の key id/generation と verification evidence を表示する。 +3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。 +4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。 +5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。 -If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity` and `trust-server` command. Otherwise the setup command may write an auth file that the server process never reads. - -Example with explicit Runtime storage: - -```bash -yoi-runtime identity init \ - --runtime-id runtime-main \ - --fs-root /var/lib/yoi-runtime - -yoi-runtime identity show \ - --json \ - --fs-root /var/lib/yoi-runtime -``` - -## 3. Register the Server public key on Runtime - -On the Runtime host, register the Server public key copied from `yoi-server identity show --json`: - -```bash -yoi-runtime trust-server add \ - --server-id server-main \ - --public-key '' -``` - -With explicit Runtime storage, keep using the same storage flags: - -```bash -yoi-runtime trust-server add \ - --server-id server-main \ - --public-key '' \ - --fs-root /var/lib/yoi-runtime -``` - -Verify: - -```bash -yoi-runtime trust-server list --json -``` - -## 4. Register the Runtime public key and endpoint on Server - -On the Workspace Server host, register the Runtime public key copied from `yoi-runtime identity show --json`: - -```bash -yoi-server trust-runtime add \ - --workspace-id '' \ - --runtime-id runtime-main \ - --base-url http://127.0.0.1:38800 \ - --public-key '' \ - --display-name 'Runtime main' -``` - -This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority. - -Verify: - -```bash -yoi-server trust-runtime list --workspace-id '' --json -``` - -## 5. Start Runtime and Workspace Server - -Start Runtime with the same storage flags used during Runtime identity/trust setup: - -```bash -yoi-runtime \ - --bind 127.0.0.1:38800 -``` - -For repository builds, the equivalent cargo command is: - -```bash -cargo run -p worker-runtime \ - --bin yoi-runtime \ - -- --bind 127.0.0.1:38800 -``` - -Start Workspace Server: - -```bash -yoi-server serve --listen 127.0.0.1:8787 -``` - -For repository builds: - -```bash -cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787 -``` - -An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration. - -## Smoke checks - -Check both trust stores: - -```bash -yoi-server trust-runtime list --workspace-id '' --json -yoi-runtime trust-server list --json -``` - -Check that Workspace Server can see Runtime workers through the authenticated path. From the CLI: - -```bash -yoi workers \ - --backend http://127.0.0.1:8787 \ - --runtime-id runtime-main -``` - -In Web, open the Workspace UI through Workspace Server and verify that Runtime worker listing, worker creation, and Console protocol input work. The protocol WebSocket uses the same Server-signed Runtime auth path as REST control calls. - -## Rotation and replacement - -Identity and trust records are intentionally not overwritten by default. - -Rotate Server identity: - -```bash -yoi-server identity init --server-id server-main --replace -``` - -After Server identity rotation, every Runtime that trusts that Server must be updated with the new Server public key: - -```bash -yoi-runtime trust-server add \ - --server-id server-main \ - --public-key '' \ - --replace -``` - -Rotate Runtime identity: - -```bash -yoi-runtime identity init --runtime-id runtime-main --replace -``` - -After Runtime identity rotation, Server must be updated with the new Runtime public key: - -```bash -yoi-server trust-runtime add \ - --workspace-id '' \ - --runtime-id runtime-main \ - --base-url http://127.0.0.1:38800 \ - --public-key '' \ - --replace -``` - -## Revocation - -Revoke a trusted Runtime on Server: - -```bash -yoi-server trust-runtime revoke \ - --workspace-id '' \ - --runtime-id runtime-main -``` - -Remove a trusted Server from Runtime: - -```bash -yoi-runtime trust-server revoke --server-id server-main -``` - -## Troubleshooting - -### `trusted runtimes are registered but server identity is not initialized` - -The Server DB contains trusted Runtime records, but the Server signing identity file does not exist. Run: - -```bash -yoi-server identity init --server-id server-main -``` - -If the identity was created in another environment, ensure the Server process is using the same Yoi data directory. - -### Runtime accepts unauthenticated requests - -Runtime only enables signed capability-token auth when both a Runtime identity and at least one trusted Server are present in its auth file. Check: - -```bash -yoi-runtime identity show --json -yoi-runtime trust-server list --json -``` - -Also confirm the Runtime process was started with the same `--fs-root` / `--fs-runtime-dir` used for setup. - -### Wrong audience or unauthorized Runtime response - -Confirm the `--runtime-id` registered on Server exactly matches the Runtime identity id: - -```bash -yoi-runtime identity show --json -yoi-server trust-runtime list --workspace-id '' --json -``` - -`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime. - -### Duplicate registration fails - -This is expected. Use `--replace` only when intentionally rotating or updating trust material. +Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。 diff --git a/scripts/isolated-startup-smoke.sh b/scripts/isolated-startup-smoke.sh deleted file mode 100755 index 26262c70..00000000 --- a/scripts/isolated-startup-smoke.sh +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Starts production Server/Runtime binaries against disposable state and ports. -# This script must never read or write the caller's Yoi data/config directories. - -repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -server_bin=${YOI_SMOKE_SERVER_BIN:-"$repo_root/target/debug/yoi-server"} -runtime_bin=${YOI_SMOKE_RUNTIME_BIN:-"$repo_root/target/debug/yoi-runtime"} -server_port=${YOI_SMOKE_SERVER_PORT:-48787} -runtime_port=${YOI_SMOKE_RUNTIME_PORT:-48800} -keep=${YOI_SMOKE_KEEP:-0} - -fail() { - printf 'isolated-startup-smoke: %s\n' "$*" >&2 - exit 1 -} - -for command in curl git node ss; do - command -v "$command" >/dev/null || fail "required command is unavailable: $command" -done -[[ -x "$server_bin" ]] || fail "Server binary is not executable: $server_bin" -[[ -x "$runtime_bin" ]] || fail "Runtime binary is not executable: $runtime_bin" -[[ "$server_port" =~ ^[0-9]+$ ]] || fail "invalid Server port: $server_port" -[[ "$runtime_port" =~ ^[0-9]+$ ]] || fail "invalid Runtime port: $runtime_port" -[[ "$server_port" != "$runtime_port" ]] || fail "Server and Runtime ports must differ" - -port_is_listening() { - local port=$1 - ss -H -ltn "sport = :$port" | grep -q . -} - -port_is_listening "$server_port" && fail "Server smoke port is already in use: $server_port" -port_is_listening "$runtime_port" && fail "Runtime smoke port is already in use: $runtime_port" - -root=$(mktemp -d "${TMPDIR:-/tmp}/yoi-isolated-startup-smoke.XXXXXX") -server_pid= -runtime_pid= - -stop_pid() { - local pid=${1:-} - [[ -n "$pid" ]] || return 0 - kill -TERM "$pid" 2>/dev/null || true - for _ in $(seq 1 50); do - kill -0 "$pid" 2>/dev/null || break - sleep 0.1 - done - if kill -0 "$pid" 2>/dev/null; then - kill -KILL "$pid" 2>/dev/null || true - fi - wait "$pid" 2>/dev/null || true -} - -cleanup() { - local status=$? - trap - EXIT INT TERM - stop_pid "$runtime_pid" - stop_pid "$server_pid" - if [[ "$keep" == 1 ]]; then - printf 'isolated-startup-smoke: kept artifacts at %s\n' "$root" >&2 - elif [[ $status -eq 0 ]]; then - rm -rf "$root" - else - printf 'isolated-startup-smoke: failed; artifacts kept at %s\n' "$root" >&2 - fi - exit "$status" -} -trap cleanup EXIT INT TERM - -mkdir -p "$root/home" "$root/data" "$root/config" "$root/repository" "$root/logs" -export HOME="$root/home" -export XDG_DATA_HOME="$root/data" -export XDG_CONFIG_HOME="$root/config" -unset YOI_DATA_DIR YOI_CONFIG_HOME - -# Fail closed if isolation variables no longer point below the disposable root. -case "$HOME:$XDG_DATA_HOME:$XDG_CONFIG_HOME" in - "$root"/*:"$root"/*:"$root"/*) ;; - *) fail "HOME/XDG isolation guard failed" ;; -esac - -server_url="http://127.0.0.1:$server_port" -runtime_url="http://127.0.0.1:$runtime_port" -server_id=isolated-smoke-server -runtime_id=isolated-smoke-runtime - -git -C "$root/repository" init -q -git -C "$root/repository" config user.email smoke@example.invalid -git -C "$root/repository" config user.name 'Yoi isolated smoke' -printf '# isolated smoke\n' >"$root/repository/README.md" -git -C "$root/repository" add README.md -git -C "$root/repository" commit -qm 'test: initialize isolated smoke repository' - -"$server_bin" identity init --server-id "$server_id" >"$root/logs/server-identity-init.log" 2>&1 -"$runtime_bin" identity init --runtime-id "$runtime_id" >"$root/logs/runtime-identity-init.log" 2>&1 -server_identity=$("$server_bin" identity show --json) -runtime_identity=$("$runtime_bin" identity show --json) -server_key=$(printf '%s' "$server_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)') -runtime_key=$(printf '%s' "$runtime_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)') - -"$server_bin" trust-runtime add \ - --runtime-id "$runtime_id" \ - --public-key "$runtime_key" \ - --base-url "$runtime_url" >"$root/logs/server-trust-runtime.log" 2>&1 -"$runtime_bin" trust-server add \ - --server-id "$server_id" \ - --public-key "$server_key" >"$root/logs/runtime-trust-server.log" 2>&1 - -"$server_bin" init --workspace "$root/repository" >"$root/logs/server-init.log" 2>&1 -workspace_id=$(sed -n 's/^workspace_id = "\([^"]*\)"/\1/p' "$root/repository/.yoi/workspace.toml") -[[ -n "$workspace_id" ]] || fail "workspace init did not write workspace_id" -# Runtime Git materialization requires a clean source repository. Commit both -# local bootstrap markers inside this disposable repository. -git -C "$root/repository" add .yoi/workspace.toml .yoi/workspace-backend.local.toml -git -C "$root/repository" commit -qm 'test: record isolated Yoi workspace markers' - -runtime_store="$XDG_DATA_HOME/yoi/runtime" -mkdir -p "$runtime_store/workers" -cat >"$runtime_store/runtime.json" <<'JSON' -{ - "schema_version": 1, - "display_name": "isolated startup smoke", - "backend": "fs_store", - "status": "running", - "next_worker_sequence": 1, - "next_diagnostic_id": 1, - "config_bundles": {}, - "workspace_owners": {}, - "diagnostics": [] -} -JSON - -start_server() { - : >"$root/logs/server.log" - "$server_bin" serve --listen "127.0.0.1:$server_port" >"$root/logs/server.log" 2>&1 & - server_pid=$! -} - -start_runtime() { - : >"$root/logs/runtime.log" - "$runtime_bin" --bind "127.0.0.1:$runtime_port" >"$root/logs/runtime.log" 2>&1 & - runtime_pid=$! -} - -wait_for_listener() { - local pid=$1 - local port=$2 - local name=$3 - for _ in $(seq 1 150); do - kill -0 "$pid" 2>/dev/null || fail "$name exited before listening; inspect $root/logs" - port_is_listening "$port" && return 0 - sleep 0.1 - done - fail "$name did not listen on port $port within 15 seconds" -} - -runtime_projection() { - curl --fail --silent --show-error \ - "$server_url/api/w/$workspace_id/runtimes" -} - -projection_is_ready() { - node -e ' -const fs = require("fs"); -const runtimeId = process.argv[1]; -const body = JSON.parse(fs.readFileSync(0, "utf8")); -const runtime = body.items.find((item) => item.runtime_id === runtimeId); -if (!runtime || runtime.status !== "running") process.exit(1); -if (!runtime.capabilities?.can_list_workers) process.exit(1); -if ((runtime.diagnostics ?? []).length !== 0) process.exit(1); -if ((body.diagnostics ?? []).length !== 0) process.exit(1); -' "$runtime_id" -} - -wait_for_projection_state() { - local expected=$1 - local body= - for _ in $(seq 1 150); do - kill -0 "$server_pid" 2>/dev/null || fail "Server exited during readiness check" - body=$(runtime_projection 2>/dev/null || true) - if [[ -n "$body" ]]; then - if printf '%s' "$body" | projection_is_ready 2>/dev/null; then - [[ "$expected" == ready ]] && return 0 - else - [[ "$expected" == not-ready ]] && return 0 - fi - fi - sleep 0.1 - done - printf '%s\n' "$body" >"$root/logs/last-runtime-projection.json" - fail "Runtime projection did not become $expected within 15 seconds" -} - -assert_clean_logs() { - if grep -Eiq 'panicked at|thread .* panicked|UNIQUE constraint failed|worker_execution_restore_failed' \ - "$root/logs/server.log" "$root/logs/runtime.log"; then - fail "panic, migration collision, or restore failure found in startup logs" - fi -} - -start_server -wait_for_listener "$server_pid" "$server_port" Server - -# Negative control: a listening Server is not readiness. The configured remote -# Runtime must be rejected while it is absent. -wait_for_projection_state not-ready - -start_runtime -wait_for_listener "$runtime_pid" "$runtime_port" Runtime -wait_for_projection_state ready -assert_clean_logs - -# Listener/catalog readiness is insufficient. Materialize a real Workdir and -# require the normal Server -> Runtime Worker spawn path to create a persisted -# Worker with an execution handle. This catches adapter panics that startup -# alone cannot observe. -repositories=$(curl --fail --silent --show-error \ - "$server_url/api/w/$workspace_id/repositories") -repository_id=$(printf '%s' "$repositories" | node -e ' -const fs = require("fs"); -const body = JSON.parse(fs.readFileSync(0, "utf8")); -if (body.items.length !== 1) process.exit(1); -process.stdout.write(body.items[0].id); -') -workdir_response=$(curl --fail --silent --show-error \ - --request POST \ - --header 'content-type: application/json' \ - --data "{\"runtime_id\":\"$runtime_id\",\"repository_id\":\"$repository_id\"}" \ - "$server_url/api/w/$workspace_id/runtimes/$runtime_id/working-directories") || \ - fail "isolated Workdir materialization failed" -working_directory_id=$(printf '%s' "$workdir_response" | node -e ' -const fs = require("fs"); -const body = JSON.parse(fs.readFileSync(0, "utf8")); -if (body.item?.status !== "active" || body.item?.cleanliness !== "clean") process.exit(1); -process.stdout.write(body.item.working_directory_id); -') - -cat >"$root/worker-create.json" <"$root/logs/restored-worker.json" || fail "persisted Worker is unavailable after Runtime restart" -assert_clean_logs - -# Prove that this run used only disposable state paths. -grep -Fq "$root/data/yoi/server/server.db" "$root/logs/server.log" || \ - fail "Server log does not identify the isolated database" -if grep -Fq '/home/hare/.local/share/yoi' "$root/logs/server.log" "$root/logs/runtime.log"; then - fail "startup logs reference a non-isolated Yoi data path" -fi - -printf 'isolated-startup-smoke: PASS (workspace=%s, server=%s, runtime=%s)\n' \ - "$workspace_id" "$server_url" "$runtime_url" diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 06cb5f1e..9688deab 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -165,6 +165,35 @@ export type WorkspaceMetadataMutationResponse = { diagnostics: Array; }; +export type WorkspaceSigningIdentityState = "pending_provisioning" | "active"; + +export type WorkspaceSigningIdentityPublic = { + workspace_id: string; + key_id: string; + algorithm: string; + public_key?: string; + public_key_fingerprint?: string; + revision: number; + state: WorkspaceSigningIdentityState; + created_at: string; + provisioned_at?: string; +}; + +export type WorkspacePublicIdentityBundle = { + workspace_id: string; + backend_url: string; + key_id: string; + algorithm: string; + public_key: string; + public_key_fingerprint: string; + revision: number; +}; + +export type WorkspaceSigningIdentityResponse = { + identity: WorkspaceSigningIdentityPublic; + public_bundle?: WorkspacePublicIdentityBundle; +}; + export type ProfileSettingsResponse = { workspace_id: string; registry_revision: string; @@ -315,12 +344,51 @@ export type RuntimeSummary = { diagnostics: Array; }; +export type WorkspaceRuntimeBindingState = + | "configured" + | "verified" + | "revoked"; + +export type RuntimeConnectionDisplayState = + | "configured" + | "verified" + | "unavailable" + | "revoked"; + +export type RuntimeVerificationOutcome = + | "verified" + | "challenge_issued" + | "verification_failed" + | "connectivity_failed"; + +export type RuntimeVerificationEvidenceSummary = { + verified_at: string | null; + last_checked_at: string; + last_outcome: RuntimeVerificationOutcome; + binding_revision: number; + workspace_key_id: string; + workspace_identity_revision: number; + workspace_trust_generation: number; + runtime_public_key_fingerprint: string; + runtime_identity_revision: number; +}; + +export type WorkspaceRuntimeBindingSummary = { + state: WorkspaceRuntimeBindingState; + connection_state: RuntimeConnectionDisplayState; + revision: number; + workspace_key_id?: string | null; + workspace_key_generation?: number | null; + verification?: RuntimeVerificationEvidenceSummary | null; +}; + export type RuntimeManagementSummary = { built_in: boolean; config_managed: boolean; removable: boolean; endpoint_configured: boolean; token_ref_configured: boolean; + binding?: WorkspaceRuntimeBindingSummary | null; }; export type WorkspaceRuntimeResource = { @@ -373,11 +441,6 @@ export type WorkspaceRuntimeDetail = { export type RuntimeTrustKeyRevealResponse = { public_key: string }; -export type PutRuntimeTrustKeyRequest = { - public_key: string; - expected_revision: number | null; -}; - export type RevokeRuntimeTrustKeyRequest = { expected_revision: number }; export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; @@ -389,6 +452,18 @@ export type RuntimeTrustConflictResponse = { current_fingerprint?: string | null; }; +export type RuntimePublicIdentityBundle = { + identity_id: string; + public_key: string; +}; + +export type CreateRemoteRuntimeRequest = { + public_bundle: RuntimePublicIdentityBundle; + display_name?: string | null; + endpoint: string; + expected_revision?: number | null; +}; + export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestFailureKind = @@ -405,6 +480,9 @@ export type RuntimeConnectionTestFailureKind = export type RuntimeConnectionTestResponse = { workspace_id: string; runtime_id: string; + binding_revision: number; + connection_state: RuntimeConnectionDisplayState; + verification: RuntimeVerificationEvidenceSummary | null; checked_at: string; status: RuntimeConnectionTestStatus; failure_kind: RuntimeConnectionTestFailureKind | null; diff --git a/web/workspace/src/lib/workspace/api/runtime-connection.ts b/web/workspace/src/lib/workspace/api/runtime-connection.ts index 6eb58965..05ec74ce 100644 --- a/web/workspace/src/lib/workspace/api/runtime-connection.ts +++ b/web/workspace/src/lib/workspace/api/runtime-connection.ts @@ -2,11 +2,15 @@ import type { Diagnostic, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, + RuntimeVerificationEvidenceSummary, } from "$lib/generated/workspace-api"; const RESPONSE_KEYS = [ "workspace_id", "runtime_id", + "binding_revision", + "connection_state", + "verification", "checked_at", "status", "failure_kind", @@ -103,9 +107,28 @@ export function parseRuntimeConnectionTestResponse( ) { return null; } + const bindingRevision = value.binding_revision; + const connectionState = parseConnectionState(value.connection_state); + const verification = parseVerificationEvidence(value.verification); + if ( + !isSafeRevision(bindingRevision) || + connectionState === null || + (value.verification !== null && verification === null) || + (verification !== null && + verification.binding_revision !== bindingRevision) || + (connectionState === "verified" && value.status !== "compatible") || + (connectionState === "verified" && verification !== null && + (verification.last_outcome !== "verified" || + verification.verified_at === null)) + ) { + return null; + } return { workspace_id: value.workspace_id, runtime_id: value.runtime_id, + binding_revision: bindingRevision, + connection_state: connectionState, + verification, checked_at: value.checked_at, status: value.status, failure_kind: failureKind as RuntimeConnectionTestFailureKind | null, @@ -115,6 +138,67 @@ export function parseRuntimeConnectionTestResponse( }; } +function parseConnectionState( + value: unknown, +): "configured" | "verified" | "unavailable" | "revoked" | null { + return value === "configured" || value === "verified" || + value === "unavailable" || value === "revoked" + ? value + : null; +} + +function isSafeRevision(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function parseVerificationEvidence( + value: unknown, +): RuntimeVerificationEvidenceSummary | null { + if (value === null) return null; + const keys = [ + "verified_at", + "last_checked_at", + "last_outcome", + "binding_revision", + "workspace_key_id", + "workspace_identity_revision", + "workspace_trust_generation", + "runtime_public_key_fingerprint", + "runtime_identity_revision", + ] as const; + if (!isRecord(value) || !hasExactKeys(value, keys)) return null; + if ( + (value.verified_at !== null && + (!isBoundedString(value.verified_at, 128) || + Number.isNaN(Date.parse(value.verified_at)))) || + !isBoundedString(value.last_checked_at, 128) || + Number.isNaN(Date.parse(value.last_checked_at)) || + (value.last_outcome !== "verified" && + value.last_outcome !== "challenge_issued" && + value.last_outcome !== "verification_failed" && + value.last_outcome !== "connectivity_failed") || + !isSafeRevision(value.binding_revision) || + !isBoundedString(value.workspace_key_id, 128) || + !isSafeRevision(value.workspace_identity_revision) || + !isSafeRevision(value.workspace_trust_generation) || + !isBoundedString(value.runtime_public_key_fingerprint, 128) || + !isSafeRevision(value.runtime_identity_revision) + ) { + return null; + } + return { + verified_at: value.verified_at, + last_checked_at: value.last_checked_at, + last_outcome: value.last_outcome, + binding_revision: value.binding_revision, + workspace_key_id: value.workspace_key_id, + workspace_identity_revision: value.workspace_identity_revision, + workspace_trust_generation: value.workspace_trust_generation, + runtime_public_key_fingerprint: value.runtime_public_key_fingerprint, + runtime_identity_revision: value.runtime_identity_revision, + }; +} + export async function testRuntimeConnection( workspaceId: string, runtimeId: string, diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index ac15d012..24cf94a5 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -1,7 +1,8 @@ import type { + CreateRemoteRuntimeRequest, Diagnostic, - PutRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest, + RuntimeConnectionDisplayState, RuntimeIdentityAuthority, RuntimeManagementSummary, RuntimeSourceKind, @@ -14,6 +15,9 @@ import type { RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, + RuntimeVerificationEvidenceSummary, + WorkspaceRuntimeBindingState, + WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, } from "$lib/generated/workspace-api.ts"; @@ -67,6 +71,17 @@ const CONFLICT_KINDS = new Set([ "stale_revision", "fingerprint_in_use", ]); +const BINDING_STATES = new Set([ + "configured", + "verified", + "revoked", +]); +const CONNECTION_STATES = new Set([ + "configured", + "verified", + "unavailable", + "revoked", +]); const encoder = new TextEncoder(); type JsonObject = Record; @@ -286,6 +301,145 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary { }; } +function runtimeVerification( + value: unknown, + path: string, +): RuntimeVerificationEvidenceSummary { + const item = object(value, path); + exactKeys( + item, + [ + "verified_at", + "last_checked_at", + "last_outcome", + "binding_revision", + "workspace_key_id", + "workspace_identity_revision", + "workspace_trust_generation", + "runtime_public_key_fingerprint", + "runtime_identity_revision", + ], + [], + path, + ); + const verifiedAt = item.verified_at === null + ? null + : boundedString(item.verified_at, `${path}.verified_at`, 128); + const lastOutcome = enumValue( + item.last_outcome, + `${path}.last_outcome`, + new Set( + [ + "verified", + "challenge_issued", + "verification_failed", + "connectivity_failed", + ] as const, + ), + ); + return { + verified_at: verifiedAt, + last_checked_at: boundedString( + item.last_checked_at, + `${path}.last_checked_at`, + 128, + ), + last_outcome: lastOutcome, + binding_revision: safeRevision( + item.binding_revision, + `${path}.binding_revision`, + ), + workspace_key_id: boundedString( + item.workspace_key_id, + `${path}.workspace_key_id`, + LIMITS.idBytes, + ), + workspace_identity_revision: safeRevision( + item.workspace_identity_revision, + `${path}.workspace_identity_revision`, + ), + workspace_trust_generation: safeRevision( + item.workspace_trust_generation, + `${path}.workspace_trust_generation`, + ), + runtime_public_key_fingerprint: boundedString( + item.runtime_public_key_fingerprint, + `${path}.runtime_public_key_fingerprint`, + LIMITS.fingerprintBytes, + ), + runtime_identity_revision: safeRevision( + item.runtime_identity_revision, + `${path}.runtime_identity_revision`, + ), + }; +} + +function runtimeBinding( + value: unknown, + path: string, +): WorkspaceRuntimeBindingSummary { + const item = object(value, path); + exactKeys( + item, + ["state", "connection_state", "revision"], + ["workspace_key_id", "workspace_key_generation", "verification"], + path, + ); + const workspaceKeyId = optionalNullableString( + item.workspace_key_id, + `${path}.workspace_key_id`, + LIMITS.idBytes, + ); + const workspaceKeyGeneration = optionalNullableRevision( + item.workspace_key_generation, + `${path}.workspace_key_generation`, + ); + const state = enumValue(item.state, `${path}.state`, BINDING_STATES); + if ( + state !== "revoked" && + (workspaceKeyId == null || workspaceKeyGeneration == null) + ) { + return fail(path, "requires Workspace signing key identity metadata"); + } + const connectionState = enumValue( + item.connection_state, + `${path}.connection_state`, + CONNECTION_STATES, + ); + const revision = safeRevision(item.revision, `${path}.revision`); + const verification = item.verification === undefined + ? undefined + : runtimeVerification(item.verification, `${path}.verification`); + if ( + verification !== undefined && verification.binding_revision !== revision + ) { + return fail(path, "verification must match the current binding revision"); + } + if ( + connectionState === "verified" && + (verification === undefined || + verification.verified_at === null || + verification.last_outcome !== "verified") + ) { + return fail( + path, + "verified Workspace identity binding requires verification evidence", + ); + } + return { + state, + connection_state: connectionState, + revision, + ...(workspaceKeyId === undefined + ? {} + : { workspace_key_id: workspaceKeyId }), + ...(workspaceKeyGeneration === undefined + ? {} + : { workspace_key_generation: workspaceKeyGeneration }), + ...(verification === undefined ? {} : { verification }), + }; +} + function runtimeManagement( value: unknown, path: string, @@ -300,9 +454,12 @@ function runtimeManagement( "endpoint_configured", "token_ref_configured", ], - [], + ["binding"], path, ); + const binding = item.binding == null + ? undefined + : runtimeBinding(item.binding, `${path}.binding`); return { built_in: boolean(item.built_in, `${path}.built_in`), config_managed: boolean(item.config_managed, `${path}.config_managed`), @@ -315,6 +472,7 @@ function runtimeManagement( item.token_ref_configured, `${path}.token_ref_configured`, ), + ...(binding === undefined ? {} : { binding }), }; } @@ -648,6 +806,26 @@ function requestErrorFrom( ): RuntimeTrustRequestError { try { const response = object(value, "Runtime trust error"); + if ("details" in response) { + exactKeys( + response, + ["error", "details"], + [], + "Runtime trust error", + ); + boundedString( + response.error, + "Runtime trust error.error", + LIMITS.idBytes, + ); + return new RuntimeTrustRequestError( + boundedString( + response.details, + "Runtime trust error.details", + LIMITS.conflictMessageBytes, + ), + ); + } exactKeys( response, ["error", "message", "diagnostics"], @@ -713,6 +891,30 @@ async function finishMutation( return detail; } +export async function createRemoteRuntime( + workspaceId: string, + request: CreateRemoteRuntimeRequest, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl( + workspaceApiPath(workspaceId, "/runtimes"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + ); + const payload = await readBoundedJson(response); + if (!response.ok) throw requestErrorFrom(payload, response.status); + const runtime = runtimeResource(payload, "Runtime create response"); + if (runtime.runtime_id !== request.public_bundle.identity_id) { + throw new RuntimeTrustRequestError( + "Runtime create response did not match the submitted public bundle", + ); + } + return runtime; +} + export async function revealRuntimeTrustKey( workspaceId: string, runtimeId: string, @@ -763,29 +965,6 @@ export async function previewRuntimePublicKeyFingerprint( return `sha256:${hex}`; } -export async function putRuntimeTrustKey( - workspaceId: string, - runtimeId: string, - request: PutRuntimeTrustKeyRequest, - fetchImpl: typeof fetch = fetch, -): Promise { - const response = await fetchImpl( - workspaceApiPath( - workspaceId, - `/runtimes/${encodeURIComponent(runtimeId)}/trust-key`, - ), - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - public_key: request.public_key, - expected_revision: revisionForJson(request.expected_revision), - }), - }, - ); - return await finishMutation(response, workspaceId, runtimeId); -} - export async function revokeRuntimeTrustKey( workspaceId: string, runtimeId: string, diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index e0ff66e7..51cbfc45 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -8,6 +8,10 @@ import type { WorkspaceProfileSourceProvenance, WorkspaceProfileSourceSummary, WorkspaceProfileSummary, + WorkspacePublicIdentityBundle, + WorkspaceSigningIdentityPublic, + WorkspaceSigningIdentityResponse, + WorkspaceSigningIdentityState, } from "$lib/generated/workspace-api"; export class ProfileApiError extends Error { @@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string { return value; } +function boundedStringValue( + value: unknown, + context: string, + maxBytes: number, +): string { + const text = stringValue(value, context); + if (new TextEncoder().encode(text).byteLength > maxBytes) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return text; +} + function booleanValue(value: unknown, context: string): boolean { if (typeof value !== "boolean") { throw new ProfileApiError(`${context} returned an invalid response.`, 502); @@ -66,6 +82,15 @@ function optionalString( return stringValue(value, context); } +function optionalBoundedString( + value: unknown, + context: string, + maxBytes: number, +): string | null | undefined { + if (value === undefined || value === null) return value; + return boundedStringValue(value, context, maxBytes); +} + function optionalRevision( value: unknown, context: string, @@ -286,6 +311,197 @@ export function parseProfileSettingsResponse( }; } +export function parseWorkspaceSigningIdentityResponse( + value: unknown, +): WorkspaceSigningIdentityResponse { + const item = record(value, "Workspace signing identity"); + exactKeys( + item, + ["identity"], + ["public_bundle"], + "Workspace signing identity", + ); + const identityItem = record(item.identity, "Workspace signing identity"); + exactKeys( + identityItem, + ["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"], + ["public_key", "public_key_fingerprint", "provisioned_at"], + "Workspace signing identity", + ); + const state = boundedStringValue( + identityItem.state, + "Workspace signing identity", + 32, + ); + if (state !== "pending_provisioning" && state !== "active") { + throw new ProfileApiError( + "Workspace signing identity returned an invalid response.", + 502, + ); + } + const revision = optionalRevision( + identityItem.revision, + "Workspace signing identity", + ); + if (revision === undefined || revision === null || revision < 1) { + throw new ProfileApiError( + "Workspace signing identity returned an invalid response.", + 502, + ); + } + const publicKey = optionalBoundedString( + identityItem.public_key, + "Workspace signing identity", + 256, + ); + const fingerprint = optionalBoundedString( + identityItem.public_key_fingerprint, + "Workspace signing identity", + 128, + ); + const provisionedAt = optionalBoundedString( + identityItem.provisioned_at, + "Workspace signing identity", + 128, + ); + const identity: WorkspaceSigningIdentityPublic = { + workspace_id: boundedStringValue( + identityItem.workspace_id, + "Workspace signing identity", + 128, + ), + key_id: boundedStringValue( + identityItem.key_id, + "Workspace signing identity", + 128, + ), + algorithm: boundedStringValue( + identityItem.algorithm, + "Workspace signing identity", + 32, + ), + ...(publicKey === undefined || publicKey === null + ? {} + : { public_key: publicKey }), + ...(fingerprint === undefined || fingerprint === null + ? {} + : { public_key_fingerprint: fingerprint }), + revision, + state: state as WorkspaceSigningIdentityState, + created_at: boundedStringValue( + identityItem.created_at, + "Workspace signing identity", + 128, + ), + ...(provisionedAt === undefined || provisionedAt === null + ? {} + : { provisioned_at: provisionedAt }), + }; + + let publicBundle: WorkspacePublicIdentityBundle | undefined; + if (item.public_bundle !== undefined) { + const bundle = record( + item.public_bundle, + "Workspace public identity bundle", + ); + exactKeys( + bundle, + [ + "workspace_id", + "backend_url", + "key_id", + "algorithm", + "public_key", + "public_key_fingerprint", + "revision", + ], + [], + "Workspace public identity bundle", + ); + const bundleRevision = optionalRevision( + bundle.revision, + "Workspace public identity bundle", + ); + if ( + bundleRevision === undefined || bundleRevision === null || + bundleRevision < 1 + ) { + throw new ProfileApiError( + "Workspace public identity bundle returned an invalid response.", + 502, + ); + } + publicBundle = { + workspace_id: boundedStringValue( + bundle.workspace_id, + "Workspace public identity bundle", + 128, + ), + backend_url: boundedStringValue( + bundle.backend_url, + "Workspace public identity bundle", + 2048, + ), + key_id: boundedStringValue( + bundle.key_id, + "Workspace public identity bundle", + 128, + ), + algorithm: boundedStringValue( + bundle.algorithm, + "Workspace public identity bundle", + 32, + ), + public_key: boundedStringValue( + bundle.public_key, + "Workspace public identity bundle", + 256, + ), + public_key_fingerprint: boundedStringValue( + bundle.public_key_fingerprint, + "Workspace public identity bundle", + 128, + ), + revision: bundleRevision, + }; + } + if ( + publicBundle !== undefined && + ( + publicBundle.workspace_id !== identity.workspace_id || + publicBundle.key_id !== identity.key_id || + publicBundle.algorithm !== identity.algorithm || + publicBundle.public_key !== identity.public_key || + publicBundle.public_key_fingerprint !== identity.public_key_fingerprint || + publicBundle.revision !== identity.revision + ) + ) { + throw new ProfileApiError( + "Workspace public identity bundle does not match identity metadata.", + 502, + ); + } + if ( + (state === "active" && + (publicBundle === undefined || identity.public_key === undefined || + identity.public_key_fingerprint === undefined || + identity.provisioned_at === undefined)) || + (state === "pending_provisioning" && + (publicBundle !== undefined || identity.public_key !== undefined || + identity.public_key_fingerprint !== undefined || + identity.provisioned_at !== undefined)) + ) { + throw new ProfileApiError( + "Workspace signing identity returned an invalid response.", + 502, + ); + } + return { + identity, + ...(publicBundle === undefined ? {} : { public_bundle: publicBundle }), + }; +} + async function parseResponse( response: Response, parser: (value: unknown) => T, @@ -325,6 +541,33 @@ export async function updateWorkspaceMetadata( ); } +export async function fetchWorkspaceSigningIdentity( + workspaceId: string, +): Promise { + return await parseResponse( + await fetch( + `/api/w/${ + encodeURIComponent(workspaceId) + }/settings/workspace/signing-identity`, + ), + parseWorkspaceSigningIdentityResponse, + ); +} + +export async function provisionWorkspaceSigningIdentity( + workspaceId: string, +): Promise { + return await parseResponse( + await fetch( + `/api/w/${ + encodeURIComponent(workspaceId) + }/settings/workspace/signing-identity/provision`, + { method: "POST" }, + ), + parseWorkspaceSigningIdentityResponse, + ); +} + export async function fetchProfileSettings( workspaceId: string, ): Promise { diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte index 35c87b2c..49929bfd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -2,20 +2,31 @@ import { invalidateAll } from '$app/navigation'; import type { RuntimeConnectionTestResponse, + RuntimePublicIdentityBundle, WorkspaceRuntimeResource, } from '$lib/generated/workspace-api'; + import { + createRemoteRuntime, + previewRuntimePublicKeyFingerprint, + RuntimeTrustRequestError, + } from '$lib/workspace/api/runtime-management'; import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection'; - import { workspaceApiPath } from '$lib/workspace/api/http'; import type { PageProps } from './$types'; + const runtimeBundlePlaceholder = + '{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}'; + let { data }: PageProps = $props(); - let runtimeId = $state(''); + let runtimePublicBundle = $state(''); let displayName = $state(''); let endpoint = $state(''); + let runtimeFingerprint = $state(null); + let fingerprintConfirmation = $state(''); let showAddRuntime = $state(false); let busyRuntimeId = $state(null); let requestError = $state(null); let testResults = $state>({}); + let connectionTestGeneration = 0; function runtimePlatform(runtime: WorkspaceRuntimeResource): string { return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown'; @@ -23,7 +34,9 @@ function connectionTestSummary(result: RuntimeConnectionTestResponse): string { if (result.status === 'compatible') { - return `Compatible · protocol v${result.actual_protocol_version}`; + return result.connection_state === 'verified' + ? `Verified · protocol v${result.actual_protocol_version}` + : `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`; } switch (result.failure_kind) { case 'authentication': return 'Authentication failed'; @@ -46,33 +59,52 @@ return 'Observed'; } - async function responseError(response: Response): Promise { - const payload = await response.json().catch(() => null) as - | { message?: string; error?: string } - | null; - return payload?.message ?? payload?.error ?? `Request failed (${response.status})`; + function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('Runtime public bundle must be valid JSON'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Runtime public bundle must be a JSON object'); + } + const item = parsed as Record; + if ( + Object.keys(item).length !== 2 || + typeof item.identity_id !== 'string' || + item.identity_id.length === 0 || + typeof item.public_key !== 'string' || + item.public_key.length === 0 + ) { + throw new Error('Runtime public bundle must contain only identity_id and public_key'); + } + return { identity_id: item.identity_id, public_key: item.public_key }; } - async function addRuntime(event: SubmitEvent): Promise { - event.preventDefault(); + function workspacePublicBundle(): string { + return data.signingIdentity?.public_bundle + ? JSON.stringify(data.signingIdentity.public_bundle, null, 2) + : ''; + } + + async function copyWorkspaceBundle(): Promise { requestError = null; - busyRuntimeId = 'create'; try { - const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - runtime_id: runtimeId, - display_name: displayName || null, - endpoint, - }), - }); - if (!response.ok) throw new Error(await responseError(response)); - runtimeId = ''; - displayName = ''; - endpoint = ''; - showAddRuntime = false; - await invalidateAll(); + await navigator.clipboard.writeText(workspacePublicBundle()); + } catch { + requestError = 'Workspace public bundle could not be copied'; + } + } + + async function previewRuntimeFingerprint(): Promise { + requestError = null; + runtimeFingerprint = null; + fingerprintConfirmation = ''; + busyRuntimeId = 'preview'; + try { + const bundle = parseRuntimePublicBundle(runtimePublicBundle); + runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key); } catch (error) { requestError = error instanceof Error ? error.message : String(error); } finally { @@ -80,16 +112,75 @@ } } + async function addRuntime(event: SubmitEvent): Promise { + event.preventDefault(); + requestError = null; + busyRuntimeId = 'create'; + try { + const publicBundle = parseRuntimePublicBundle(runtimePublicBundle); + const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key); + if ( + runtimeFingerprint !== currentFingerprint || + fingerprintConfirmation.trim() !== currentFingerprint + ) { + throw new Error('Preview and confirm the exact Runtime public key fingerprint before registration'); + } + await createRemoteRuntime(data.workspaceId, { + public_bundle: publicBundle, + display_name: displayName || null, + endpoint, + expected_revision: null, + }); + runtimePublicBundle = ''; + runtimeFingerprint = null; + fingerprintConfirmation = ''; + displayName = ''; + endpoint = ''; + showAddRuntime = false; + await invalidateAll(); + } catch (error) { + requestError = error instanceof RuntimeTrustRequestError || error instanceof Error + ? error.message + : String(error); + } finally { + busyRuntimeId = null; + } + } + + function currentTestResult( + runtime: WorkspaceRuntimeResource, + ): RuntimeConnectionTestResponse | undefined { + const result = testResults[runtime.runtime_id]; + return result?.binding_revision === runtime.management?.binding?.revision + ? result + : undefined; + } + async function testRuntime(runtime: WorkspaceRuntimeResource): Promise { + const bindingRevision = runtime.management?.binding?.revision; + if (typeof bindingRevision !== 'number') return; + const generation = ++connectionTestGeneration; requestError = null; busyRuntimeId = runtime.runtime_id; try { const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id); + if ( + generation !== connectionTestGeneration || + result.binding_revision !== bindingRevision + ) { + await invalidateAll(); + return; + } testResults = { ...testResults, [runtime.runtime_id]: result }; + await invalidateAll(); } catch (error) { - requestError = error instanceof Error ? error.message : String(error); + if (generation === connectionTestGeneration) { + requestError = error instanceof Error ? error.message : String(error); + } } finally { - busyRuntimeId = null; + if (generation === connectionTestGeneration) { + busyRuntimeId = null; + } } } @@ -116,10 +207,36 @@

Add remote Runtime

-
+
+

Trust this Workspace on the Runtime

+ {#if data.signingIdentityError} +

{data.signingIdentityError}

+ {:else if data.signingIdentity?.public_bundle} +

+ Save this public bundle as workspace-public-bundle.json on the Runtime host. + It contains no private key material. +

+
{workspacePublicBundle()}
+ +
yoi-runtime trust-workspace add --bundle workspace-public-bundle.json
+

+ Runtime registration remains configured until authenticated verification is completed. +

+ {:else} +

Loading Workspace public identity…

+ {/if} +
- + @@ -174,7 +313,9 @@ {runtime.runtime_id} {runtime.kind} - {runtime.status} + + {runtime.management?.binding?.connection_state ?? runtime.status} + {runtimePlatform(runtime)} {managementLabel(runtime)} @@ -184,20 +325,23 @@
- {#if runtime.management?.config_managed} + {#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'} {/if} - {#if !runtime.management?.config_managed} + {#if runtime.management?.binding?.state === 'configured'} + Verification required + {:else if !runtime.management?.config_managed} Test unavailable {/if}
- {#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]} + {@const currentResult = currentTestResult(runtime)} + {#if runtime.diagnostics.length > 0 || currentResult} {#if runtime.diagnostics.length > 0} @@ -210,14 +354,13 @@ {/each} {/if} - {#if testResults[runtime.runtime_id]} - {@const result = testResults[runtime.runtime_id]} -
- Connection test: {connectionTestSummary(result)} - {#if result.diagnostics[0]} - {result.diagnostics[0].message} + {#if currentResult} +
+ Connection test: {connectionTestSummary(currentResult)} + {#if currentResult.diagnostics[0]} + {currentResult.diagnostics[0].message} {/if} - Checked {new Date(result.checked_at).toLocaleString()} + Checked {new Date(currentResult.checked_at).toLocaleString()}
{/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts index cf7f727b..37ed3880 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts @@ -1,5 +1,6 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; +import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { @@ -16,9 +17,24 @@ export const load: PageLoad = async ({ fetch, params }) => { }, ); + const signingIdentity = await loadJson( + fetch, + workspaceApiPath(params.workspaceId, "/signing-identity"), + undefined, + (value) => { + const response = parseWorkspaceSigningIdentityResponse(value); + if (response.identity.workspace_id !== params.workspaceId) { + throw new Error("Workspace signing identity did not match the route"); + } + return response; + }, + ); + return { workspaceId: params.workspaceId, runtimes: runtimes.data, runtimesError: runtimes.error, + signingIdentity: signingIdentity.data, + signingIdentityError: signingIdentity.error, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index bceedc8f..27c73576 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -1,13 +1,12 @@