fix: authenticate Runtime requests without persisted verification

Use the current Workspace signing identity for each request and remove binding key copies and verification handshake gates. Preserve explicit trust and revocation, migrate historical evidence, and align Runtime settings and connection tests. Retain request scope and durable replay protection with regression coverage.
This commit is contained in:
2026-10-11 00:52:24 +09:00
parent 6a12b6cc34
commit 4099359560
42 changed files with 2786 additions and 4411 deletions
+1 -15
View File
@@ -1139,10 +1139,6 @@ pub struct RemainingRuntimeRoute {
pub reason: &'static str,
}
pub const RUNTIME_ROUTE_VERIFICATION_CHALLENGE: &str =
"/v1/workspace-runtime-verification/challenge";
pub const RUNTIME_ROUTE_VERIFICATION_ACK: &str =
"/v1/workspace-runtime-verification/acknowledgement";
pub const RUNTIME_ROUTE_CONFIG_BUNDLES: &str = "/v1/config-bundles";
pub const RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY: &str =
"/v1/config-bundles/{bundle_id}/availability";
@@ -1169,16 +1165,6 @@ pub const RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT: &str =
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
/// than letting them be mistaken for management-contract omissions.
pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
RemainingRuntimeRoute {
method: "POST",
path: RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
reason: "Workspace verification handshake",
},
RemainingRuntimeRoute {
method: "POST",
path: RUNTIME_ROUTE_VERIFICATION_ACK,
reason: "Workspace verification handshake",
},
RemainingRuntimeRoute {
method: "GET",
path: RUNTIME_ROUTE_CONFIG_BUNDLES,
@@ -1625,7 +1611,7 @@ mod tests {
#[test]
fn remaining_route_inventory_does_not_overlap_contract() {
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 21);
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 19);
for remaining in REMAINING_RUNTIME_ROUTES {
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
+33 -82
View File
@@ -8837,62 +8837,14 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[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, JsonSchema)]
#[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, JsonSchema)]
#[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, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeVerificationEvidenceSummary {
pub verified_at: Option<String>,
pub last_checked_at: String,
pub last_outcome: RuntimeVerificationOutcome,
pub binding_id: String,
pub workspace_key_id: String,
pub workspace_public_key_fingerprint: String,
pub workspace_trust_id: String,
pub runtime_public_key_fingerprint: String,
}
/// Binding identity and explicit revocation only. Each signed request authenticates
/// using the current Workspace signing identity, not a stored approval.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
pub binding_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_trust_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<RuntimeVerificationEvidenceSummary>,
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -9076,9 +9028,6 @@ pub struct RuntimePublicIdentityBundle {
#[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest {
pub public_bundle: RuntimePublicIdentityBundle,
/// Exact Runtime-issued enrollment identity from `trust-workspace add/show`.
/// A key fingerprint alone cannot distinguish revocation and re-enrollment.
pub workspace_trust_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub endpoint: String,
@@ -9121,12 +9070,11 @@ pub enum RuntimeConnectionTestFailureKind {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
/// Observation of an actual signed ping, not a grant or cached approval.
pub struct RuntimeConnectionTestResponse {
pub workspace_id: String,
pub runtime_id: String,
pub binding_id: String,
pub connection_state: RuntimeConnectionDisplayState,
pub verification: Option<RuntimeVerificationEvidenceSummary>,
pub checked_at: String,
pub status: RuntimeConnectionTestStatus,
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
@@ -12626,18 +12574,22 @@ mod tests {
}
#[test]
fn runtime_registration_requires_explicit_runtime_issued_trust_identity() {
let mut value = serde_json::json!({
fn runtime_registration_uses_runtime_bundle_without_workspace_identity_copies() {
let value = serde_json::json!({
"public_bundle": {"identity_id": "runtime", "public_key": "public-key"},
"endpoint": "https://runtime.example.test",
"workspace_trust_id": "runtime-issued-enrollment"
"endpoint": "https://runtime.example.test"
});
let request: CreateRemoteRuntimeRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(request.workspace_trust_id, "runtime-issued-enrollment");
value.as_object_mut().unwrap().remove("workspace_trust_id");
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value.clone()).is_err());
value["workspace_key_generation"] = serde_json::json!(1);
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value).is_err());
assert_eq!(serde_json::to_value(request).unwrap(), value);
for field in [
"workspace_trust_id",
"workspace_key_id",
"workspace_key_generation",
] {
let mut obsolete = value.clone();
obsolete[field] = serde_json::json!("obsolete");
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(obsolete).is_err());
}
}
#[test]
@@ -12646,8 +12598,6 @@ mod tests {
"workspace_id": "workspace-test",
"runtime_id": "runtime-test",
"binding_id": "binding-test",
"connection_state": "verified",
"verification": null,
"checked_at": "2026-09-01T12:00:00Z",
"status": "compatible",
"failure_kind": null,
@@ -12665,21 +12615,22 @@ mod tests {
}
#[test]
fn runtime_trust_contract_binds_concrete_keys_and_binding_lifetimes() {
let evidence = RuntimeVerificationEvidenceSummary {
verified_at: Some("2026-10-06T00:00:00Z".into()),
last_checked_at: "2026-10-06T00:00:00Z".into(),
last_outcome: RuntimeVerificationOutcome::Verified,
binding_id: "binding-new".into(),
workspace_key_id: "workspace-key".into(),
workspace_public_key_fingerprint: "sha256:workspace-key".into(),
workspace_trust_id: "trust-new".into(),
runtime_public_key_fingerprint: "sha256:runtime-key".into(),
};
round_trip(evidence.clone());
let mut stale = serde_json::to_value(evidence).unwrap();
stale["unexpected_state_guard"] = serde_json::json!(1);
assert!(serde_json::from_value::<RuntimeVerificationEvidenceSummary>(stale).is_err());
fn runtime_binding_rejects_cached_authentication_and_requires_observed_identity_for_mutation() {
let binding = serde_json::json!({"binding_id": "binding-new", "revoked_at": null});
let parsed: WorkspaceRuntimeBindingSummary =
serde_json::from_value(binding.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), binding);
for field in [
"state",
"connection_state",
"workspace_key_id",
"workspace_trust_id",
"verification",
] {
let mut obsolete = binding.clone();
obsolete[field] = serde_json::json!("obsolete");
assert!(serde_json::from_value::<WorkspaceRuntimeBindingSummary>(obsolete).is_err());
}
round_trip(RevokeRuntimeTrustKeyRequest {
expected_binding_id: "binding-observed".into(),
});
+15
View File
@@ -27,4 +27,19 @@ cargo run -p worker-runtime \
```
The REST server is intended for a trusted Backend/proxy, not direct browser access.
Workspace requests authenticate the actual HTTP request with the Workspace's current
signing identity. The Runtime verifies signatures against its explicitly enrolled
Workspace public key and binds the Runtime audience, Workspace/Worker scope,
operation, method, path/query, body digest, expiry, and single-use token ID. A
connection test is an ordinary signed `/v1/ping`; no prior handshake or saved
verification evidence is required or consulted.
Keep `trust-workspace add`, `replace`, and `revoke` as explicit operator actions.
A self-reported public key cannot grant trust. Trust record IDs are administrative
only and must not be copied into Backend registration or request claims. Replay
protection uses Workspace plus the trusted key fingerprint, so re-enrolling the
same key or rotating away and back cannot reset consumed requests. Reaccepting a
key does permit fresh, unconsumed requests signed by that key until their short
expiry; it does not create a separate sender-controlled enrollment namespace.
For authenticated remote Runtime setup, see [`../../docs/development/server-runtime-auth.md`](../../docs/development/server-runtime-auth.md).
+272 -627
View File
@@ -31,11 +31,7 @@ use crate::ssh_host_key_probe::{
SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program,
};
use crate::workspace_issuer::{
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_OPERATION,
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority,
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
WorkspaceRuntimeVerificationRecord, WorkspaceRuntimeVerificationResponse,
inspect_workspace_capability_claims, workspace_request_body_digest,
};
use crate::{Runtime, RuntimeWorkspaceScope};
@@ -256,14 +252,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
};
let router = Router::new()
.route(
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE, &["POST"]),
post(post_workspace_verification_challenge),
)
.route(
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK, &["POST"]),
post(post_workspace_verification_acknowledgement),
)
.route(
remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]),
get(list_config_bundles).post(store_config_bundle),
@@ -377,8 +365,7 @@ struct RuntimeHttpState {
#[derive(Clone, Debug)]
pub struct WorkspaceRuntimeHttpAuth {
pub verifier: WorkspaceCapabilityVerifier,
pub signer: RuntimeVerificationSigner,
pub verifications: Arc<dyn WorkspaceRuntimeVerificationAuthority>,
pub runtime_id: String,
}
struct RuntimeHttpWorkdirSession {
@@ -563,172 +550,6 @@ fn unix_now_i64() -> i64 {
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX)
}
async fn post_workspace_verification_challenge(
State(state): State<RuntimeHttpState>,
Extension(verified): Extension<VerifiedWorkspaceCapability>,
Json(challenge): Json<WorkspaceRuntimeVerificationChallenge>,
) -> RestResult<WorkspaceRuntimeVerificationResponse> {
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_id != verified.binding_id
|| challenge.workspace_key_id != verified.issuer_key_id
|| challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|| challenge.workspace_trust_id != verified.trust_id
|| 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(),
)
})?;
auth.verifications.begin(challenge).map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
})?;
Ok(Json(response))
}
async fn post_workspace_verification_acknowledgement(
State(state): State<RuntimeHttpState>,
Extension(verified): Extension<VerifiedWorkspaceCapability>,
Json(acknowledgement): Json<WorkspaceRuntimeVerificationAcknowledgement>,
) -> RestResult<WorkspaceRuntimeVerificationReceipt> {
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_id != verified.binding_id
|| acknowledgement.workspace_key_id != verified.issuer_key_id
|| acknowledgement.workspace_public_key_fingerprint
!= verified.issuer_public_key_fingerprint
|| acknowledgement.workspace_trust_id != verified.trust_id
|| 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_id.is_empty()
|| acknowledgement.workspace_public_key_fingerprint.is_empty()
|| acknowledgement.workspace_trust_id.is_empty()
|| 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_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
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
.acknowledge(
&acknowledgement.challenge_id,
WorkspaceRuntimeVerificationRecord {
workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(),
binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_public_key_fingerprint: acknowledgement
.workspace_public_key_fingerprint
.clone(),
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement
.runtime_public_key_fingerprint
.clone(),
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_id: acknowledgement.binding_id.clone(),
accepted_at: unix_now_i64(),
}))
}
#[allow(dead_code)]
async fn get_runtime_ping(
State(state): State<RuntimeHttpState>,
@@ -757,7 +578,7 @@ async fn get_runtime_ping(
let runtime_id = state
.workspace_auth
.as_ref()
.map(|auth| auth.signer.runtime_id().trim())
.map(|auth| auth.runtime_id.trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
RuntimeHttpRestError::new(
@@ -2123,8 +1944,7 @@ async fn require_runtime_auth(
let body_digest = workspace_request_body_digest(&body);
let expected = WorkspaceCapabilityExpectation {
workspace_id: &claims.issuer_workspace_id,
binding_id: &claims.binding_id,
runtime_id: workspace_auth.signer.runtime_id(),
runtime_id: &workspace_auth.runtime_id,
worker_id: expected_worker_id.as_deref(),
operation: required_permission,
method: &method,
@@ -2134,48 +1954,6 @@ async fn require_runtime_auth(
};
match workspace_auth.verifier.verify(token, &expected) {
Ok(verified) => {
let is_verification = path_and_query
== runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|| path_and_query == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK;
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_id != verified.binding_id
|| record.workspace_key_id != verified.issuer_key_id
|| record.workspace_public_key_fingerprint
!= verified.issuer_public_key_fingerprint
|| record.workspace_trust_id != verified.trust_id
|| record.runtime_public_key_fingerprint
!= workspace_auth.signer.public_key_fingerprint()
{
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 {
@@ -2199,13 +1977,7 @@ async fn require_runtime_auth(
}
}
let workspace_bootstrap_request = request.method() == Method::POST
&& matches!(
request.uri().path(),
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
| runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK
);
if state.workspace_auth.is_some() && !workspace_bootstrap_request {
if state.workspace_auth.is_some() {
let local_token_matches = state
.local_token
.as_deref()
@@ -2275,12 +2047,6 @@ fn auth_workspace_scope(
}
fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str {
if (path == runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|| path == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
&& *method == Method::POST
{
return WORKSPACE_VERIFICATION_OPERATION;
}
required_runtime_permission(method, path).unwrap_or("runtime:read")
}
@@ -2661,12 +2427,10 @@ mod tests {
WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::management::RuntimeOptions;
use crate::retention::{DiagnosticsDisposition, SessionDisposition};
use crate::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, issue_workspace_capability_token,
verify_runtime_verification_response,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityClaims,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
issue_workspace_capability_token,
};
use axum::body::to_bytes;
use axum::http::Method;
@@ -2741,8 +2505,6 @@ mod tests {
.map(|route| route.path)
.collect::<std::collections::BTreeSet<_>>();
for path in [
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK,
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES,
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
@@ -2766,402 +2528,286 @@ mod tests {
}
}
#[tokio::test]
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
runtime
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
"builtin:coder".to_string(),
)))
.unwrap();
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.clone(),
trust_id: "trust-test".into(),
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.clone(),
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_id: "binding-test".into(),
workspace_key_id: "workspace-key".to_string(),
workspace_public_key_fingerprint: workspace_fingerprint.clone(),
workspace_trust_id: "trust-test".into(),
runtime_public_key_fingerprint: runtime_fingerprint,
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_public_key_fingerprint: workspace_fingerprint.clone(),
trust_id: "trust-test".into(),
binding_id: "binding-test".into(),
runtime_id: "runtime-test".to_string(),
worker_id: None,
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
method: "POST".to_string(),
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE.to_string(),
body_digest: workspace_request_body_digest(&body),
iat: unix_now_i64(),
exp: challenge.expires_at,
jti: "challenge-token".to_string(),
};
let token =
issue_workspace_capability_token(&workspace_identity.signing_key().unwrap(), &claims)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE)
.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::<WorkspaceRuntimeVerificationResponse>(&response_body).unwrap();
verify_runtime_verification_response(
&verification_response,
&challenge,
&runtime_identity.public_key,
unix_now_i64(),
)
.unwrap();
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_id: verification_response.binding_id.clone(),
workspace_key_id: verification_response.workspace_key_id.clone(),
workspace_public_key_fingerprint: verification_response
.workspace_public_key_fingerprint
.clone(),
workspace_trust_id: verification_response.workspace_trust_id.clone(),
runtime_public_key_fingerprint: verification_response
.runtime_public_key_fingerprint
.clone(),
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: runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK.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(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
.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!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
for authorization in [None, Some("Bearer malformed")] {
let mut request = Request::builder()
.method(Method::POST)
.uri(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES);
if let Some(authorization) = authorization {
request = request.header(header::AUTHORIZATION, authorization);
}
let response = app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
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
.clone()
.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!(response.status(), StatusCode::OK);
let mut retention_create = task_request("retention-scope");
retention_create.workspace_api = Some(WorkspaceApiRef {
workspace_id: "workspace-a".to_string(),
base_url: "https://backend.test".to_string(),
});
let retention_worker_id = retention_create.worker_id.clone();
runtime.create_worker(retention_create).unwrap();
let retention_path = format!("/v1/workers/{retention_worker_id}/retention/execute");
let retention_request = WorkerRetentionExecutionRequest {
operation_id: "retention-op".to_string(),
input_fingerprint: "fingerprint".to_string(),
archive_id: None,
workspace_id: "workspace-b".to_string(),
source_runtime_id: "runtime-a".to_string(),
worker_id: retention_worker_id,
source_created_at: "2025-01-01T00:00:00Z".to_string(),
removed_at: "2025-01-02T00:00:00Z".to_string(),
effective_profile: None,
retention_class: None,
policy_id: "policy".to_string(),
session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Retain,
};
let retention_body = serde_json::to_vec(&retention_request).unwrap();
let retention_claims = WorkspaceCapabilityClaims {
operation: required_runtime_permission(&Method::POST, &retention_path)
.unwrap()
.to_string(),
method: "POST".to_string(),
path_and_query: retention_path.clone(),
worker_id: Some(retention_worker_id.to_string()),
body_digest: workspace_request_body_digest(&retention_body),
jti: "retention-scope-token".to_string(),
..ping_claims
};
let retention_token = issue_workspace_capability_token(
&workspace_identity.signing_key().unwrap(),
&retention_claims,
)
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri(retention_path)
.header(header::AUTHORIZATION, format!("Bearer {retention_token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(retention_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert_eq!(error.error.code, "worker_not_found");
}
#[tokio::test]
async fn reenrolled_binding_trust_or_keys_require_fresh_signed_verification() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let fingerprint = |key: &str| {
format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
crate::auth::decode_public_key(key).unwrap()
))
)
};
let old_trust = WorkspaceIssuerTrustRecord::from_bundle(
fn workspace_trust(identity: &RuntimeIdentityMaterial) -> WorkspaceIssuerTrustRecord {
WorkspaceIssuerTrustRecord::from_bundle(
server_api::WorkspacePublicIdentityBundle {
workspace_id: "workspace-a".into(),
backend_url: "https://backend.test".into(),
key_id: "workspace-key".into(),
key_id: identity.identity_id.clone(),
algorithm: "ed25519".into(),
public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: fingerprint(&workspace_identity.public_key),
public_key: identity.public_key.clone(),
public_key_fingerprint: format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
crate::auth::decode_public_key(&identity.public_key).unwrap()
))
),
},
"old-trust".into(),
"administrative-trust-id".into(),
1,
)
.unwrap();
let old_verification = WorkspaceRuntimeVerificationRecord {
workspace_id: old_trust.workspace_id.clone(),
.unwrap()
}
fn signed_claims(
trust: &WorkspaceIssuerTrustRecord,
method: &str,
path: &str,
body: &[u8],
) -> WorkspaceCapabilityClaims {
WorkspaceCapabilityClaims {
issuer: trust.backend_url.clone(),
issuer_workspace_id: trust.workspace_id.clone(),
issuer_key_id: trust.key_id.clone(),
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
runtime_id: "runtime-test".into(),
binding_id: "old-binding".into(),
workspace_key_id: old_trust.key_id.clone(),
workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(),
workspace_trust_id: old_trust.trust_id.clone(),
runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key),
verified_at: 1,
};
for changed in ["binding", "trust", "workspace_key", "runtime_key"] {
let mut current = old_verification.clone();
let mut trust = old_trust.clone();
let mut workspace_key = workspace_identity.clone();
let mut runtime_key = runtime_identity.clone();
match changed {
"binding" => current.binding_id = "reenrolled-binding".into(),
"trust" => {
trust.trust_id = "reaccepted-trust".into();
current.workspace_trust_id = trust.trust_id.clone();
}
"workspace_key" => {
workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap();
trust.key_id = workspace_key.identity_id.clone();
trust.public_key = workspace_key.public_key.clone();
trust.public_key_fingerprint = fingerprint(&workspace_key.public_key);
current.workspace_key_id = trust.key_id.clone();
current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone();
}
"runtime_key" => {
runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key);
}
_ => unreachable!(),
worker_id: worker_id_from_runtime_path(path),
operation: workspace_runtime_operation(
&Method::from_bytes(method.as_bytes()).unwrap(),
path,
)
.into(),
method: method.into(),
path_and_query: path.into(),
body_digest: workspace_request_body_digest(body),
iat: unix_now_i64(),
exp: unix_now_i64() + 60,
jti: uuid::Uuid::now_v7().to_string(),
}
}
fn workspace_test_auth(records: Vec<WorkspaceIssuerTrustRecord>) -> WorkspaceRuntimeHttpAuth {
WorkspaceRuntimeHttpAuth {
runtime_id: "runtime-test".into(),
verifier: WorkspaceCapabilityVerifier::new(
records,
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
}
}
async fn signed_request(
app: Router,
identity: &RuntimeIdentityMaterial,
claims: &WorkspaceCapabilityClaims,
body: Vec<u8>,
workspace: &str,
) -> Response {
let token =
issue_workspace_capability_token(&identity.signing_key().unwrap(), claims).unwrap();
app.oneshot(
Request::builder()
.method(claims.method.as_str())
.uri(&claims.path_and_query)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, workspace)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
async fn ordinary_signed_ping_succeeds_without_handshake_and_rejects_replay() {
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let trust = workspace_trust(&identity);
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
workspace_test_auth(vec![trust.clone()]),
);
let claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
let response = signed_request(app.clone(), &identity, &claims, vec![], "workspace-a").await;
assert_eq!(response.status(), StatusCode::OK);
let ping: runtime_api::RuntimePingResponse = read_json(response).await;
assert_eq!(ping.runtime_id, "runtime-test");
assert_eq!(ping.protocol_version, RUNTIME_HTTP_PROTOCOL_VERSION);
let response = signed_request(app, &identity, &claims, vec![], "workspace-a").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert_eq!(error.error.code, "unauthorized");
assert!(error.error.message.contains("already consumed"));
}
#[tokio::test]
async fn signed_ping_rejects_untrusted_revoked_wrong_key_signature_and_workspace_scope() {
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let other_key = RuntimeIdentityMaterial::generate("other-key").unwrap();
let trust = workspace_trust(&identity);
for case in [
"untrusted",
"revoked",
"bad_signature",
"wrong_key",
"wrong_runtime",
"wrong_operation",
"expired",
"wrong_workspace_scope",
"unsigned",
] {
let mut current = trust.clone();
if case == "revoked" {
current.state = WorkspaceIssuerTrustState::Revoked;
}
let authority = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
authority.record(old_verification.clone()).unwrap();
let records = if case == "untrusted" {
vec![]
} else {
vec![current]
};
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
WorkspaceRuntimeHttpAuth {
verifier: WorkspaceCapabilityVerifier::new(
vec![trust.clone()],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(),
verifications: authority.clone(),
},
workspace_test_auth(records),
);
let claims = WorkspaceCapabilityClaims {
issuer: trust.backend_url.clone(),
issuer_workspace_id: trust.workspace_id.clone(),
issuer_key_id: trust.key_id.clone(),
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
trust_id: trust.trust_id.clone(),
binding_id: current.binding_id.clone(),
runtime_id: current.runtime_id.clone(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: "/v1/ping".into(),
body_digest: workspace_request_body_digest(&[]),
iat: unix_now_i64(),
exp: unix_now_i64() + 60,
jti: "stale-verification".into(),
};
for (expected, token_id) in [
(StatusCode::FORBIDDEN, "stale-verification"),
(StatusCode::OK, "fresh-verification"),
] {
if expected == StatusCode::OK {
authority.record(current.clone()).unwrap();
let mut claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
match case {
"wrong_key" => claims.issuer_key_id = "untrusted-key".into(),
"wrong_runtime" => claims.runtime_id = "another-runtime".into(),
"wrong_operation" => claims.operation = "workers:create".into(),
"expired" => {
claims.iat -= 120;
claims.exp -= 120;
}
let token = issue_workspace_capability_token(
&workspace_key.signing_key().unwrap(),
&WorkspaceCapabilityClaims {
jti: token_id.into(),
..claims.clone()
_ => {}
}
let response = if case == "unsigned" {
app.oneshot(
Request::builder()
.uri("/v1/ping")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
} else {
signed_request(
app,
if case == "bad_signature" {
&other_key
} else {
&identity
},
&claims,
vec![],
if case == "wrong_workspace_scope" {
"workspace-b"
} else {
"workspace-a"
},
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
expected,
"{changed}: {}",
String::from_utf8_lossy(&body)
);
if expected == StatusCode::FORBIDDEN {
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(error.error.code, "workspace_runtime_verification_stale");
}
}
.await
};
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
if case == "wrong_workspace_scope" {
StatusCode::FORBIDDEN
} else {
StatusCode::UNAUTHORIZED
},
"{case}: {}",
String::from_utf8_lossy(&bytes)
);
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
error.error.code,
if case == "wrong_workspace_scope" {
"runtime_ping_workspace_scope_mismatch"
} else {
"unauthorized"
},
"{case}"
);
}
}
#[tokio::test]
async fn ordinary_signed_workdir_operation_succeeds_without_evidence_and_rejects_changed_body()
{
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("hello.txt"), "hello").unwrap();
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let trust = workspace_trust(&identity);
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("wd-1"),
temp.path().to_path_buf(),
temp.path().to_path_buf(),
SharedScope::new(Scope::writable(temp.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
));
let state = RuntimeHttpState {
runtime: Runtime::new_memory(),
local_token: None,
workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))),
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
"session-1".into(),
RuntimeHttpWorkdirSession {
owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"),
session,
},
)]))),
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
};
let app = Router::new()
.route(
"/v1/workdir-sessions/{session_id}/operations",
post(run_workdir_session_operation),
)
.with_state(state.clone())
.layer(middleware::from_fn_with_state(state, require_runtime_auth));
let body = serde_json::to_vec(&WorkdirSessionOperationRequest {
operation: WorkdirSessionOperation::Stat(StatRequest {
path: WorkdirPath::new("hello.txt").unwrap(),
}),
})
.unwrap();
let claims = signed_claims(
&trust,
"POST",
"/v1/workdir-sessions/session-1/operations",
&body,
);
let response = signed_request(app.clone(), &identity, &claims, body, "workspace-a").await;
assert_eq!(response.status(), StatusCode::OK);
let result: WorkdirSessionOperationResult = read_json(response).await;
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let claims = WorkspaceCapabilityClaims {
jti: uuid::Uuid::now_v7().to_string(),
..claims
};
let response = signed_request(app, &identity, &claims, b"{}".to_vec(), "workspace-a").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert!(error.error.message.contains("request body"));
}
#[tokio::test]
async fn removed_handshake_routes_are_not_network_aliases() {
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let trust = workspace_trust(&identity);
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
workspace_test_auth(vec![trust.clone()]),
);
for path in [
"/v1/workspace-runtime-verification/challenge",
"/v1/workspace-runtime-verification/acknowledgement",
] {
let claims = signed_claims(&trust, "POST", path, b"{}");
let response = signed_request(
app.clone(),
&identity,
&claims,
b"{}".to_vec(),
"workspace-a",
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
@@ -4630,8 +4276,7 @@ mod tests {
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&identity).unwrap(),
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
runtime_id: identity.identity_id.clone(),
},
);
(runtime, app)
@@ -164,7 +164,7 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.state
.workspace_auth
.as_ref()
.map(|auth| auth.signer.runtime_id().trim())
.map(|auth| auth.runtime_id.trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
runtime_api::RuntimeApiError::new(
+126 -54
View File
@@ -27,11 +27,11 @@ use worker_runtime::http_server::{
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::RuntimeGitMaterializer;
use worker_runtime::workspace_issuer::{
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
FileWorkspaceClaimReplayProtection, MAX_WORKSPACE_ISSUER_TRUST_RECORDS,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, add_workspace_issuer_trust,
replace_workspace_issuer_trust, revoke_workspace_issuer_trust,
validate_workspace_issuer_trust_records,
};
use worker_runtime::workspace_request::RuntimeWorkspaceRequestClient;
use worker_runtime::{Runtime, RuntimeOptions};
@@ -972,7 +972,7 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
if migrated {
// Commit the revoked migration before exposing any issuer authority.
// Persist the validated migration without changing saved trust permissions.
write_runtime_auth_file(path, &auth)?;
}
Ok(auth)
@@ -1025,25 +1025,15 @@ fn load_workspace_runtime_http_auth(
let Some(identity) = auth.identity else {
return Ok(None);
};
if auth.workspace_issuers.is_empty() {
return Ok(None);
}
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,
)),
runtime_id: identity.identity_id,
}))
}
@@ -1407,6 +1397,34 @@ mod tests {
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
}
#[test]
fn runtime_identity_with_no_trusted_workspace_does_not_disable_request_authentication() {
let temp = tempfile::tempdir().unwrap();
let config = ProcessConfig {
fs_root: Some(temp.path().to_path_buf()),
..ProcessConfig::default().unwrap()
};
let path = runtime_auth_path(&config);
write_runtime_auth_file(
&path,
&RuntimeAuthFile {
identity: Some(RuntimeIdentityMaterial::generate("runtime-test").unwrap()),
..RuntimeAuthFile::default()
},
)
.unwrap();
// Obsolete evidence is neither a startup prerequisite nor live authority.
let evidence = path.with_extension("workspace-verifications.json");
std::fs::write(&evidence, "obsolete evidence, not even valid JSON").unwrap();
let auth = load_workspace_runtime_http_auth(&config).unwrap().unwrap();
assert_eq!(auth.runtime_id, "runtime-test");
assert!(!auth.verifier.has_active_workspace_issuer("workspace-a"));
assert_eq!(
std::fs::read_to_string(&evidence).unwrap(),
"obsolete evidence, not even valid JSON"
);
}
#[test]
fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() {
use server_api::WorkspacePublicIdentityBundle;
@@ -1454,38 +1472,6 @@ mod tests {
let first = read_runtime_auth_file(&path).unwrap();
assert_eq!(first.workspace_issuers.len(), 1);
assert!(!first.workspace_issuers[0].trust_id.is_empty());
// `trust-workspace show` serializes this persisted record. Owner
// registration must forward that exact enrollment ID, not derive one
// from the key fingerprint or an initial counter.
let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap();
let mut registration_wire = serde_json::json!({
"public_bundle": {
"identity_id": runtime_identity.identity_id,
"public_key": runtime_identity.public_key,
},
"endpoint": "https://runtime.example.test",
"workspace_trust_id": shown["trust_id"],
});
let registration: server_api::CreateRemoteRuntimeRequest =
serde_json::from_value(registration_wire.clone()).unwrap();
assert_eq!(
registration.workspace_trust_id,
first.workspace_issuers[0].trust_id
);
assert_ne!(
registration.workspace_trust_id,
first.workspace_issuers[0].public_key_fingerprint
);
registration_wire
.as_object_mut()
.unwrap()
.remove("workspace_trust_id");
assert!(
serde_json::from_value::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
.is_err()
);
run_trust_workspace_command(VecDeque::from([
"add".to_string(),
"--bundle".to_string(),
@@ -1645,9 +1631,14 @@ mod tests {
assert_eq!(error, "invalid trust-workspace list arguments");
}
#[test]
fn pre_id_issuer_migration_retains_history_without_restoring_authority() {
#[tokio::test]
async fn pre_id_issuer_migration_preserves_saved_permission_for_ordinary_signed_ping() {
use sha2::{Digest as _, Sha256};
use tower::ServiceExt;
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, issue_workspace_capability_token,
workspace_request_body_digest,
};
let identity = RuntimeIdentityMaterial::generate("WK-legacy").unwrap();
let public_key = decode_public_key(&identity.public_key).unwrap();
@@ -1680,16 +1671,97 @@ mod tests {
std::fs::write(&path, toml::to_string(&document).unwrap()).unwrap();
let migrated = read_runtime_auth_file(&path).unwrap();
assert_eq!(
migrated.workspace_issuers[0].state,
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
let expected_state = if state == "active" {
WorkspaceIssuerTrustState::Active
} else {
WorkspaceIssuerTrustState::Revoked
};
let record = &migrated.workspace_issuers[0];
assert_eq!(record.state, expected_state);
assert_eq!(record.workspace_id, "workspace-1");
assert_eq!(record.backend_url, "https://backend.example.test");
assert_eq!(record.key_id, "WK-legacy");
assert_eq!(record.public_key, identity.public_key);
assert_eq!(record.public_key_fingerprint, fingerprint);
assert_eq!(record.registered_at_unix, 10);
assert_eq!(record.updated_at_unix, 20);
// Migration is durably saved by the loader itself, not just by a later write.
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
let claims = WorkspaceCapabilityClaims {
issuer: record.backend_url.clone(),
issuer_workspace_id: record.workspace_id.clone(),
issuer_key_id: record.key_id.clone(),
issuer_public_key_fingerprint: record.public_key_fingerprint.clone(),
runtime_id: "runtime-test".into(),
worker_id: None,
operation: worker_runtime::http_server::RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: "/v1/ping".into(),
body_digest: workspace_request_body_digest(&[]),
iat: i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
exp: i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap() + 60,
jti: uuid::Uuid::now_v7().to_string(),
};
let token = issue_workspace_capability_token(&identity.signing_key().unwrap(), &claims)
.unwrap();
let app = worker_runtime::http_server::runtime_http_router_with_workspace_auth(
Runtime::new_memory(), None, WorkspaceRuntimeHttpAuth {
runtime_id: "runtime-test".into(),
verifier: WorkspaceCapabilityVerifier::new(migrated.workspace_issuers.clone(),
Arc::new(worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default())).unwrap(),
},
);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/v1/ping")
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
.header(
worker_runtime::http_server::RUNTIME_WORKSPACE_SCOPE_HEADER,
"workspace-1",
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
status,
if state == "active" {
axum::http::StatusCode::OK
} else {
axum::http::StatusCode::UNAUTHORIZED
},
"{state}: {}",
String::from_utf8_lossy(&body)
);
if state == "active" {
let ping: runtime_api::RuntimePingResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(ping.runtime_id, "runtime-test");
} else {
let error: worker_runtime::http_server::RuntimeHttpErrorResponse =
serde_json::from_slice(&body).unwrap();
assert_eq!(error.error.code, "unauthorized");
assert!(error.error.message.contains("revoked"));
}
assert_eq!(migrated.archived_legacy_issuers.len(), 1);
let archived: toml::Value =
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
assert_eq!(archived, original_record);
write_runtime_auth_file(&path, &migrated).unwrap();
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
if state == "active" {
// A current saved Revoked record stays revoked even if its archived
// pre-ID source was Active. Never guess revocation provenance.
let mut saved_revoked = migrated.clone();
saved_revoked.workspace_issuers[0].state = WorkspaceIssuerTrustState::Revoked;
write_runtime_auth_file(&path, &saved_revoked).unwrap();
assert_eq!(read_runtime_auth_file(&path).unwrap(), saved_revoked);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -817,17 +817,12 @@ mod current_authority {
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
let store = SqliteWorkspaceStore::in_memory().unwrap();
let workspace_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
.unwrap()
.1;
store.with_conn(|conn| {
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');
INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519','public','sha256:workspace','material','active','created','provisioned','updated');")?;
Ok(())
}).unwrap();
let request = WorkspaceRuntimeBinding {
@@ -838,11 +833,7 @@ mod current_authority {
public_key: runtime_key.public_key,
public_key_fingerprint: String::new(),
binding_id: String::new(),
state: WorkspaceRuntimeBindingState::Configured,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("workspace-key".into()),
workspace_public_key_fingerprint: Some(workspace_fingerprint),
workspace_trust_id: Some("runtime-issued-trust".into()),
created_at: "created".into(),
updated_at: "updated".into(),
revoked_at: None,
@@ -853,36 +844,10 @@ mod current_authority {
(store, binding)
}
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
WorkspaceRuntimeVerificationEvidence {
workspace_id: binding.workspace_id.clone(),
runtime_id: binding.runtime_id.clone(),
binding_id: binding.binding_id.clone(),
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
workspace_public_key_fingerprint: binding
.workspace_public_key_fingerprint
.clone()
.unwrap(),
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
challenge_id: "challenge".into(),
state: "verified".into(),
last_outcome: "verified".into(),
verified_at: Some("verified-at".into()),
checked_at: "checked-at".into(),
}
}
#[test]
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
fn same_key_reactivation_fences_old_config_and_admin_mutations() {
let (store, binding) = binding_store();
let old_proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&old_proof)
.unwrap();
store
.complete_workspace_runtime_verification(&old_proof)
.unwrap();
assert!(store.workspace_runtime_binding_matches(&binding).unwrap());
let (_, revoked) = store
.revoke_workspace_runtime_binding_key(
"space",
@@ -893,16 +858,22 @@ mod current_authority {
)
.unwrap();
assert_ne!(binding.binding_id, revoked.binding_id);
let mut replacement = binding.clone();
replacement.state = WorkspaceRuntimeBindingState::Configured;
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
assert!(!store.workspace_runtime_binding_matches(&revoked).unwrap());
let (_, reactivated) = store
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
.put_workspace_runtime_binding_key(binding.clone(), Some(&revoked.binding_id), "owner")
.unwrap();
assert_eq!(
binding.public_key_fingerprint,
reactivated.public_key_fingerprint
);
assert_ne!(binding.binding_id, reactivated.binding_id);
assert!(
store
.workspace_runtime_binding_matches(&reactivated)
.unwrap()
);
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
assert!(matches!(
store.put_workspace_runtime_binding_key(
reactivated.clone(),
@@ -921,42 +892,27 @@ mod current_authority {
),
Err(Error::RuntimeBindingIdConflict { .. })
));
assert!(
store
.record_workspace_runtime_verification_attempt(&old_proof)
.is_err()
);
assert!(
store
.complete_workspace_runtime_verification(&old_proof)
.is_err()
);
assert!(
!store
.workspace_runtime_verification_matches(
&reactivated,
&old_proof.workspace_public_key_fingerprint,
&old_proof.workspace_trust_id
)
.unwrap()
);
}
#[test]
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
fn endpoint_aba_fences_old_config_without_copying_workspace_signing_identity() {
let (store, binding) = binding_store();
let proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&proof)
.unwrap();
store
.complete_workspace_runtime_verification(&proof)
let renamed = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Renamed",
&binding.base_url,
"renamed-at",
)
.unwrap();
assert_eq!(binding.binding_id, renamed.binding_id);
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
let moved = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"Renamed",
"https://other.test",
"changed",
)
@@ -965,35 +921,166 @@ mod current_authority {
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"Renamed",
"https://remote.test",
"changed-back",
)
.unwrap();
assert_ne!(moved.binding_id, binding.binding_id);
assert_ne!(returned.binding_id, binding.binding_id);
assert!(
store
.record_workspace_runtime_verification_attempt(&proof)
.is_err()
);
let current = evidence(&returned);
store
.record_workspace_runtime_verification_attempt(&current)
.unwrap();
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
assert!(
store
.complete_workspace_runtime_verification(&current)
.is_err()
);
assert!(!store.workspace_runtime_binding_matches(&renamed).unwrap());
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'", [])?; Ok(()) }).unwrap();
assert_eq!(
store
.get_workspace_runtime_binding("space", "remote")
.unwrap(),
Some(returned.clone())
);
assert!(store.workspace_runtime_binding_matches(&returned).unwrap());
}
fn rows(conn: &Connection, sql: &str) -> Vec<Vec<rusqlite::types::Value>> {
let mut stmt = conn.prepare(sql).unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
}
fn schema89_bindings() -> Connection {
let conn = legacy_binding_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations VALUES(89,'descriptive legacy evidence columns')",
[],
)
.unwrap();
conn.execute_batch("INSERT INTO workspace_runtime_bindings VALUES
('space','configured-trust','Configured','https://configured.test','key-c','fp-c','binding-c','configured','workspace_identity','workspace-key','sha256:workspace','trust-c','created-c','updated-c',NULL),
('space','verified','Verified','https://verified.test','key-v','fp-v','binding-v','verified','workspace_identity','workspace-key','sha256:workspace','trust-v','created-v','updated-v',NULL),
('space','revoked-null-trust','Revoked','https://revoked.test','key-r','fp-r','binding-r','revoked','workspace_identity','workspace-key',NULL,NULL,'created-r','updated-r','revoked-r'),
('space','legacy-verified','Legacy','https://legacy.test','key-l','fp-l','binding-l','verified','legacy_server_issuer',NULL,NULL,NULL,'created-l','updated-l',NULL),
('space','legacy-revoked','Legacy revoked','https://legacy-r.test','key-lr','fp-lr','binding-lr','revoked','legacy_server_issuer',NULL,NULL,NULL,'created-lr','updated-lr','revoked-lr');
INSERT INTO workspace_runtime_verifications VALUES
('space','verified','binding-v','workspace-key','sha256:workspace','trust-v','fp-v','challenge-v','verified','verified','verified-at','checked-v'),
('space','configured-trust','binding-c','workspace-key','sha256:workspace','trust-c','fp-c','challenge-c','pending','challenge_issued',NULL,'checked-c'),
('space','revoked-null-trust','binding-r','workspace-key','sha256:workspace','old-trust','fp-r','challenge-r','failed','verification_failed',NULL,'checked-r');").unwrap();
conn
}
#[test]
fn schema90_preserves_connections_and_archives_all_auth_metadata_without_reenrollment() {
let conn = schema89_bindings();
let connections = rows(
&conn,
"SELECT workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at,revoked_at FROM workspace_runtime_bindings ORDER BY runtime_id",
);
let metadata = rows(
&conn,
"SELECT workspace_id,runtime_id,binding_id,state,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,updated_at FROM workspace_runtime_bindings ORDER BY runtime_id",
);
let proofs = rows(
&conn,
"SELECT * FROM workspace_runtime_verifications ORDER BY runtime_id",
);
let identities = rows(&conn, "SELECT * FROM workspace_signing_identities");
let audit = rows(&conn, "SELECT * FROM workspace_runtime_binding_audit");
let removal = rows(&conn, "SELECT * FROM runtime_removal_operations");
migrate_runtime_binding_connections_v89_to_v90(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
),
connections
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
),
metadata
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
),
proofs
);
assert_eq!(
rows(&conn, "SELECT * FROM workspace_signing_identities"),
identities
);
assert_eq!(
rows(&conn, "SELECT * FROM workspace_runtime_binding_audit"),
audit
);
assert_eq!(
rows(&conn, "SELECT * FROM runtime_removal_operations"),
removal
);
verify_runtime_binding_connection_schema(&conn).unwrap();
assert!(!table_exists(&conn, "workspace_runtime_verifications").unwrap());
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
);
assert_eq!(
rows(&conn, "SELECT * FROM pragma_foreign_key_check"),
Vec::<Vec<rusqlite::types::Value>>::new()
);
conn.execute_batch("UPDATE workspace_runtime_bindings SET base_url='https://moved.test',binding_id='binding-new' WHERE runtime_id='verified';
UPDATE workspace_runtime_bindings SET revoked_at='admin-revoked' WHERE runtime_id='configured-trust';
DELETE FROM workspace_runtime_bindings WHERE runtime_id='legacy-revoked';").unwrap();
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
),
metadata
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
),
proofs
);
conn.execute(
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
[],
)
.unwrap();
assert!(conn.execute("UPDATE workspace_runtime_bindings SET display_name='Blocked' WHERE runtime_id='remote'", []).unwrap_err().to_string().contains("runtime_removal_in_progress"));
}
#[test]
fn schema90_failed_rebuild_rolls_back_connections_archives_and_schema_marker() {
let conn = schema89_bindings();
// An incompatible preexisting archive is not permission to discard historical evidence.
conn.execute_batch("CREATE TABLE workspace_runtime_verification_archive(existing TEXT);")
.unwrap();
let before = rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id",
);
assert!(migrate_runtime_binding_connections_v89_to_v90(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 89);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
),
before
);
assert!(!table_exists(&conn, "workspace_runtime_binding_verification_archive").unwrap());
assert!(table_exists(&conn, "workspace_runtime_verifications").unwrap());
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
.state,
WorkspaceRuntimeBindingState::Configured
);
}
}
+27 -228
View File
@@ -2,9 +2,7 @@ use crate::Error;
use crate::resource_broker::BackendResourceBroker;
#[cfg(test)]
use crate::resource_broker::BackendResourceTarget;
use crate::store::{
ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState,
};
use crate::store::{ControlPlaneStore, WorkspaceRuntimeAuthenticationMode};
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
use chrono::Utc;
use protocol::Segment;
@@ -66,11 +64,7 @@ use worker_runtime::ssh_host_key_probe::{
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
SshHostKeyProbeResponse,
};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
WorkspaceRuntimeVerificationResponse, workspace_request_body_digest,
};
use worker_runtime::workspace_issuer::{WorkspaceCapabilityClaims, workspace_request_body_digest};
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
@@ -925,32 +919,6 @@ 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<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
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<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
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<InternalHostSummary>;
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
@@ -2277,48 +2245,6 @@ 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<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
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<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
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<Arc<dyn WorkspaceWorkerRuntime>> {
self.runtimes
.read()
@@ -3518,7 +3444,9 @@ pub struct WorkspaceRuntimeAuthorization {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
backend_url: String,
binding: Arc<RwLock<Option<crate::store::WorkspaceRuntimeBinding>>>,
// The connection selected for this client. Compare it with the current
// administrative configuration before sending, never with a past auth result.
binding: crate::store::WorkspaceRuntimeBinding,
}
impl std::fmt::Debug for WorkspaceRuntimeAuthorization {
@@ -3535,19 +3463,13 @@ impl WorkspaceRuntimeAuthorization {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
backend_url: impl Into<String>,
binding: Option<crate::store::WorkspaceRuntimeBinding>,
binding: crate::store::WorkspaceRuntimeBinding,
) -> 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);
binding,
}
}
@@ -3559,30 +3481,14 @@ impl WorkspaceRuntimeAuthorization {
worker_id: Option<&str>,
body: &[u8],
) -> Result<String, RuntimeDiagnostic> {
let binding = self
.binding
.read()
.map_err(|_| {
diagnostic(
"workspace_runtime_authorization_unavailable",
HostDiagnosticSeverity::Error,
"Workspace Runtime authorization is unavailable".to_string(),
)
})?
.clone()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_verification_required",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is not verified".to_string(),
)
})?;
if binding.state != WorkspaceRuntimeBindingState::Verified
|| binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
let binding = &self.binding;
// A removed/replaced endpoint must not be used by an old client. This
// compares connection configuration, not cached authentication success.
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|| binding.revoked_at.is_some()
|| !self
.store
.workspace_runtime_binding_matches(&binding)
.workspace_runtime_binding_matches(binding)
.map_err(|error| {
diagnostic(
"workspace_runtime_authorization_unavailable",
@@ -3594,7 +3500,7 @@ impl WorkspaceRuntimeAuthorization {
return Err(diagnostic(
"workspace_runtime_authorization_stale",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding changed or was revoked".to_string(),
"Workspace Runtime connection changed or was revoked".to_string(),
));
}
let identity = self
@@ -3607,63 +3513,26 @@ impl WorkspaceRuntimeAuthorization {
error.to_string(),
)
})?;
let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace key".to_string(),
)
})?;
let workspace_public_key_fingerprint = binding
.workspace_public_key_fingerprint
.as_deref()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace public key fingerprint"
.to_string(),
)
})?;
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
)
})?;
if identity.state != "active"
|| identity.key_id != workspace_key_id
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|| !self
.store
.workspace_runtime_verification_matches(
&binding,
workspace_public_key_fingerprint,
workspace_trust_id,
)
.map_err(|error| {
diagnostic(
"workspace_runtime_authorization_unavailable",
HostDiagnosticSeverity::Error,
error.to_string(),
)
})?
{
if identity.state != "active" {
return Err(diagnostic(
"workspace_runtime_authorization_stale",
"workspace_signing_identity_unavailable",
HostDiagnosticSeverity::Error,
"Workspace signing identity no longer matches the verified binding".to_string(),
"Workspace signing identity is not active".to_string(),
));
}
let fingerprint = identity.public_key_fingerprint.ok_or_else(|| {
diagnostic(
"workspace_signing_identity_unavailable",
HostDiagnosticSeverity::Error,
"Workspace signing identity has no public key fingerprint".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_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
trust_id: workspace_trust_id.to_string(),
binding_id: binding.binding_id.clone(),
issuer_public_key_fingerprint: fingerprint,
runtime_id: binding.runtime_id.clone(),
worker_id: worker_id.map(str::to_string),
operation: operation.to_string(),
@@ -3674,6 +3543,8 @@ impl WorkspaceRuntimeAuthorization {
exp: now.saturating_add(60),
jti: uuid::Uuid::now_v7().to_string(),
};
// The signer validates that these claims still name the selected current
// identity; a concurrent rotation cannot silently sign mismatched claims.
self.signing_identities
.issue_workspace_capability(&binding.workspace_id, &claims)
.map_err(|error| {
@@ -4251,49 +4122,6 @@ impl RemoteWorkerRuntime {
format!("{base}/v1/workers/{worker_id}/protocol/ws")
}
fn post_bearer_json<T, U>(
&self,
path: &str,
body: &T,
bearer_token: &str,
) -> Result<U, RuntimePingFailure>
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::<U>().map_err(|error| {
RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_workspace_verification_invalid_response",
format!("Runtime verification response was invalid: {error}"),
)
})
}
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
@@ -4702,36 +4530,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
})
}
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<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
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<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
self.post_bearer_json(
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_ACK_PATH,
acknowledgement,
bearer_token,
)
}
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary> {
if limit == 0 {
return RuntimeList::new(Vec::new(), Vec::new());
@@ -6536,6 +6334,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
#[cfg(test)]
mod tests {
mod restore_auth_tests;
mod workspace_auth_tests;
use super::*;
use serde_json::json;
@@ -9,36 +9,18 @@ use worker_runtime::{
auth::RuntimeIdentityMaterial,
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, WorkspaceRuntimeVerificationAuthority,
WorkspaceRuntimeVerificationRecord, issue_workspace_capability_token,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, issue_workspace_capability_token,
},
};
#[tokio::test]
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let now = Utc::now().timestamp();
let workspace_public_key_fingerprint =
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
.unwrap();
let binding_id = "binding-restore-test".to_string();
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
verifications
.record(WorkspaceRuntimeVerificationRecord {
workspace_id: "workspace-test".into(),
runtime_id: "runtime-test".into(),
binding_id: binding_id.clone(),
workspace_trust_id: "trust-restore-test".to_string(),
workspace_key_id: "workspace-key".into(),
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
verified_at: now,
})
.unwrap();
let public_key =
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
let verifier = WorkspaceCapabilityVerifier::new(
@@ -69,8 +51,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
None,
WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications,
runtime_id: "runtime-test".into(),
},
);
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
@@ -93,8 +74,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
"wrong-worker",
"wrong-body",
"wrong-key-fingerprint",
"wrong-binding-id",
"wrong-trust-id",
] {
let claims = WorkspaceCapabilityClaims {
issuer: "https://backend.test".into(),
@@ -111,16 +90,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
workspace_public_key_fingerprint.clone()
},
binding_id: if case == "wrong-binding-id" {
"binding-retired".to_string()
} else {
binding_id.clone()
},
trust_id: if case == "wrong-trust-id" {
"trust-retired".to_string()
} else {
"trust-restore-test".to_string()
},
runtime_id: "runtime-test".into(),
worker_id: if case == "wrong-worker" {
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
@@ -176,27 +145,16 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
assert_eq!(
status,
if case == "wrong-binding-id" {
StatusCode::FORBIDDEN
} else {
StatusCode::UNAUTHORIZED
},
StatusCode::UNAUTHORIZED,
"{path} {case}: {}",
error.error.message
);
assert_eq!(
error.error.code,
if case == "wrong-binding-id" {
"workspace_runtime_verification_stale"
} else {
"unauthorized"
}
);
assert_eq!(error.error.code, "unauthorized");
let expected = match case {
"wrong-operation" => Some("does not authorize this operation"),
"wrong-worker" => Some("targets another Worker"),
"wrong-body" => Some("does not bind this request body"),
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
"wrong-key-fingerprint" => None,
_ => unreachable!(),
};
if let Some(expected) = expected {
@@ -0,0 +1,316 @@
use super::*;
use crate::store::{
AccountRecord, SqliteWorkspaceStore, WorkspaceRecord, WorkspaceRuntimeBinding,
WorkspaceSigningIdentityRecord,
};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, public_key_fingerprint,
};
use worker_runtime::auth::RuntimeIdentityMaterial;
use worker_runtime::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, VerifiedWorkspaceCapability,
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerificationError,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
const WORKSPACE: &str = "workspace-auth-test";
const RUNTIME: &str = "runtime-auth-test";
const BACKEND: &str = "https://backend.test";
const PATH: &str = "/v1/ping?probe=1";
struct Fixture {
_temp: tempfile::TempDir,
store: Arc<SqliteWorkspaceStore>,
materials: Arc<InMemoryWorkspaceSigningMaterialStore>,
service: WorkspaceSigningIdentityService,
authorizer: WorkspaceRuntimeAuthorization,
binding: WorkspaceRuntimeBinding,
identity: WorkspaceSigningIdentityRecord,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "owner".into(),
kind: "user".into(),
handle: "owner".into(),
display_name: "Owner".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: WORKSPACE.into(),
owner_account_id: "owner".into(),
display_name: "Workspace".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let identity = service.provision_existing(WORKSPACE, "owner").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
store
.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: WORKSPACE.into(),
runtime_id: RUNTIME.into(),
display_name: "Runtime".into(),
base_url: "https://runtime.test".into(),
public_key_fingerprint: public_key_fingerprint(&runtime_identity.public_key)
.unwrap(),
public_key: runtime_identity.public_key,
binding_id: String::new(),
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
},
false,
)
.unwrap();
let binding = store
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap();
let authorizer = WorkspaceRuntimeAuthorization::new(
store.clone(),
service.clone(),
BACKEND,
binding.clone(),
);
Self {
_temp: temp,
store,
materials,
service,
authorizer,
binding,
identity,
}
}
fn issue(&self) -> Result<String, RuntimeDiagnostic> {
self.authorizer
.issue("GET", PATH, RUNTIME_PING_PERMISSION, None, b"")
}
fn verifier(&self) -> WorkspaceCapabilityVerifier {
WorkspaceCapabilityVerifier::new(
vec![WorkspaceIssuerTrustRecord {
workspace_id: WORKSPACE.into(),
backend_url: BACKEND.into(),
key_id: self.identity.key_id.clone(),
algorithm: "ed25519".into(),
public_key: self.identity.public_key.clone().unwrap(),
public_key_fingerprint: self.identity.public_key_fingerprint.clone().unwrap(),
trust_id: uuid::Uuid::now_v7().to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: Utc::now().timestamp(),
updated_at_unix: Utc::now().timestamp(),
}],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap()
}
// Fixture-only rotation: deliberately no production rotation API or live database.
fn rotate_identity(&mut self) {
let key_id = "WK-rotated";
let material_ref = format!("workspace-signing/{WORKSPACE}/{key_id}");
let material = WorkspaceSigningPrivateMaterial::generate(WORKSPACE, key_id).unwrap();
let public_key = material.validate_and_public_key(WORKSPACE, key_id).unwrap();
let fingerprint = public_key_fingerprint(&public_key).unwrap();
self.materials
.put_if_absent(&material_ref, &material)
.unwrap();
self.store.with_conn(|conn| {
conn.execute("UPDATE workspace_signing_identities SET key_id = ?1, public_key = ?2, public_key_fingerprint = ?3, private_material_ref = ?4 WHERE workspace_id = ?5",
rusqlite::params![key_id, public_key, fingerprint, material_ref, WORKSPACE])?;
Ok(())
}).unwrap();
self.identity = self.service.get_validated(WORKSPACE).unwrap();
}
}
fn verify(
verifier: &WorkspaceCapabilityVerifier,
token: &str,
) -> Result<VerifiedWorkspaceCapability, WorkspaceCapabilityVerificationError> {
verifier.verify(
token,
&WorkspaceCapabilityExpectation {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: None,
operation: RUNTIME_PING_PERMISSION,
method: "GET",
path_and_query: PATH,
body_digest: &workspace_request_body_digest(b""),
now_unix: Utc::now().timestamp(),
},
)
}
#[tokio::test]
async fn authorizer_token_authenticates_actual_request_without_prior_verification_record() {
let fixture = Fixture::new().await;
let token = fixture.issue().unwrap();
let verifier = fixture.verifier();
let authenticated = verify(&verifier, &token).unwrap();
assert_eq!(authenticated.workspace_id, WORKSPACE);
assert_eq!(authenticated.issuer_key_id, fixture.identity.key_id);
assert_eq!(
verify(&verifier, &token).unwrap_err(),
WorkspaceCapabilityVerificationError::Replay
);
let token = fixture.issue().unwrap();
let wrong_request = WorkspaceCapabilityExpectation {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: None,
operation: RUNTIME_PING_PERMISSION,
method: "GET",
path_and_query: "/v1/ping?probe=2",
body_digest: &workspace_request_body_digest(b""),
now_unix: Utc::now().timestamp(),
};
assert_eq!(
verifier.verify(&token, &wrong_request).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongPathAndQuery
);
}
#[tokio::test]
async fn existing_authorizer_selects_rotated_workspace_key_and_new_runtime_trust() {
let mut fixture = Fixture::new().await;
let old_verifier = fixture.verifier();
let old_token = fixture.issue().unwrap();
fixture.rotate_identity();
let new_token = fixture.issue().unwrap();
let new_verifier = fixture.verifier();
assert_eq!(
verify(&new_verifier, &new_token).unwrap().issuer_key_id,
"WK-rotated"
);
assert_eq!(
verify(&new_verifier, &old_token).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongIssuerKey
);
assert_eq!(
verify(&old_verifier, &new_token).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongIssuerKey
);
// Changing Workspace authority did not replace or copy metadata into the connection.
assert_eq!(
fixture
.store
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap(),
fixture.binding
);
}
#[tokio::test]
async fn replaced_or_revoked_connection_rejects_stale_authorizer() {
for revoked in [false, true] {
let fixture = Fixture::new().await;
fixture.issue().unwrap();
if revoked {
fixture
.store
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2")
.unwrap();
} else {
let mut replacement = fixture.binding.clone();
replacement.base_url = "https://replacement-runtime.test".into();
fixture
.store
.upsert_workspace_runtime_binding(replacement, true)
.unwrap();
}
assert_eq!(
fixture.issue().unwrap_err().code,
"workspace_runtime_authorization_stale"
);
}
}
#[tokio::test]
async fn missing_workspace_signing_material_rejects_authorization_without_fallback() {
let fixture = Fixture::new().await;
fixture
.materials
.delete(&fixture.identity.private_material_ref)
.unwrap();
let error = fixture.issue().unwrap_err();
assert_eq!(error.code, "workspace_runtime_authorization_unavailable");
assert!(
error.message.contains("private material is missing"),
"{}",
error.message
);
}
#[tokio::test]
async fn capability_claims_must_match_current_workspace_identity_before_signing() {
let mut fixture = Fixture::new().await;
let now = Utc::now().timestamp();
let mut claims = WorkspaceCapabilityClaims {
issuer: BACKEND.into(),
issuer_workspace_id: WORKSPACE.into(),
issuer_key_id: fixture.identity.key_id.clone(),
issuer_public_key_fingerprint: fixture.identity.public_key_fingerprint.clone().unwrap(),
runtime_id: RUNTIME.into(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: PATH.into(),
body_digest: workspace_request_body_digest(b""),
iat: now,
exp: now + 60,
jti: uuid::Uuid::now_v7().to_string(),
};
let token = fixture
.service
.issue_workspace_capability(WORKSPACE, &claims)
.unwrap();
verify(&fixture.verifier(), &token).unwrap();
for field in ["workspace", "key", "fingerprint"] {
let mut mismatched = claims.clone();
match field {
"workspace" => mismatched.issuer_workspace_id = "another-workspace".into(),
"key" => mismatched.issuer_key_id = "WK-another".into(),
"fingerprint" => {
mismatched.issuer_public_key_fingerprint = format!("sha256:{}", "0".repeat(64))
}
_ => unreachable!(),
}
assert!(
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &mismatched),
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch"),
"{field}"
);
}
fixture.rotate_identity();
assert!(
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &claims),
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch")
);
claims.issuer_key_id = fixture.identity.key_id.clone();
claims.issuer_public_key_fingerprint = fixture.identity.public_key_fingerprint.clone().unwrap();
let token = fixture
.service
.issue_workspace_capability(WORKSPACE, &claims)
.unwrap();
verify(&fixture.verifier(), &token).unwrap();
}
+21 -27
View File
@@ -442,49 +442,43 @@ CREATE TABLE workspace_runtime_bindings (
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK (length(binding_id) > 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_public_key_fingerprint TEXT,
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 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_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
);
CREATE TABLE workspace_runtime_verifications (
-- Historical authentication metadata only, never current admission authority.
CREATE TABLE workspace_runtime_binding_verification_archive (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
binding_id TEXT NOT NULL,
state TEXT NOT NULL,
workspace_key_id TEXT,
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, binding_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_runtime_verification_archive (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
workspace_key_id TEXT NOT NULL,
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
workspace_public_key_fingerprint TEXT NOT NULL,
workspace_trust_id TEXT NOT NULL,
runtime_public_key_fingerprint TEXT NOT NULL,
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
state TEXT NOT NULL,
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))
PRIMARY KEY (workspace_id, runtime_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
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,
@@ -48,7 +48,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
upgraded
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 89);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(evidence_rows(conn, "artifacts"), artifacts);
assert_eq!(
evidence_rows(conn, "repository_secret_legacy_receipts"),
+2 -10
View File
@@ -10,7 +10,7 @@ use yoi_workspace_server::hosts::{
};
use yoi_workspace_server::store::{
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
};
use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
@@ -456,7 +456,7 @@ fn append_workspace_runtime_sources(
.into_iter()
.filter(|binding| {
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
&& binding.state == WorkspaceRuntimeBindingState::Verified
&& binding.revoked_at.is_none()
})
.collect::<Vec<_>>()
})
@@ -787,11 +787,7 @@ mod tests {
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -815,11 +811,7 @@ mod tests {
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
workspace_trust_id: Some("trust-test".to_owned()),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -53,7 +53,8 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
"flow_source_contents",
"repositories",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_verification_archive",
"workspace_runtime_verification_archive",
"workspace_runtime_binding_audit",
"workspace_signing_identities",
"workspace_signing_identity_provisioning_operations",
File diff suppressed because it is too large Load Diff
@@ -111,11 +111,9 @@ impl RealDriveFixture {
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_id: "binding-drive-test".to_string(),
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
@@ -160,12 +160,10 @@ impl LaunchFixture {
if remote {
fixture.runtime_id = "ticket-worker-real-http".to_string();
use worker_runtime::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection,
InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let workspace_identity = fixture
.api
.signing_identities
@@ -204,13 +202,11 @@ impl LaunchFixture {
display_name: "Ticket Worker real HTTP Runtime".into(),
base_url: endpoint.clone(),
public_key: runtime_identity.public_key.clone(),
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
public_key_fingerprint: String::new(),
binding_id: "binding-launch-test".to_string(),
state: StoredRuntimeBindingState::Configured,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some(workspace_identity.key_id),
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
workspace_trust_id: Some("trust-launch-test".to_string()),
created_at: TEST_CREATED_AT.into(),
updated_at: TEST_CREATED_AT.into(),
revoked_at: None,
@@ -228,6 +224,7 @@ impl LaunchFixture {
.await
.unwrap()
.unwrap();
let runtime_id = fixture.runtime_id.clone();
fixture.runtime_server = Some(tokio::spawn(async move {
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
runtime,
@@ -235,10 +232,7 @@ impl LaunchFixture {
None,
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications: Arc::new(
InMemoryWorkspaceRuntimeVerificationAuthority::default(),
),
runtime_id,
},
)
.await
@@ -258,27 +252,12 @@ impl LaunchFixture {
cached_status: "active".into(),
timeout: std::time::Duration::from_secs(5),
};
fixture.api.runtime.register_or_replace(
RemoteWorkerRuntime::new(
remote_config.clone(),
TEST_WORKSPACE_ID.to_string(),
backend_url.clone(),
)
.unwrap(),
);
let verified = perform_workspace_runtime_verification(
&fixture.api,
fixture.api.runtime.clone(),
&binding,
)
.await
.unwrap();
remote_config.workspace_authorization =
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
fixture.api.store.clone(),
fixture.api.signing_identities.clone(),
backend_url.clone(),
Some(verified),
binding,
));
fixture.api.runtime.register_or_replace(
RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url)
@@ -694,7 +694,6 @@ async fn revoked_runtime_and_persisted_workspace_fence_reject_new_requests() {
.unwrap()
.unwrap();
binding.revoked_at = Some("revoked".into());
binding.state = StoredRuntimeBindingState::Revoked;
f.api
.store
.upsert_workspace_runtime_binding_record(binding, true)
File diff suppressed because it is too large Load Diff
@@ -99,7 +99,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_resource_keys",
"workspace_runtime_binding_audit",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_verification_archive",
"workspace_runtime_verification_archive",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
@@ -228,36 +228,7 @@ impl WorkspaceSigningIdentityService {
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
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 fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing public key fingerprint is missing",
)
})?;
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
if public_key_fingerprint(&public_key)? != fingerprint {
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
let (_, signing_key) = self.select_signing_key(workspace_id)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
@@ -266,9 +237,20 @@ impl WorkspaceSigningIdentityService {
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let (identity, signing_key) = self.select_signing_key(workspace_id)?;
if claims.issuer_workspace_id != identity.workspace_id
|| claims.issuer_key_id != identity.key_id
|| Some(claims.issuer_public_key_fingerprint.as_str())
!= identity.public_key_fingerprint.as_deref()
{
return Err(identity_error(
"workspace_capability_identity_mismatch",
"Workspace capability claims do not match the current signing identity",
));
}
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)
let signature = signing_key.sign(input.bytes());
assemble_workspace_capability_token(input, signature.as_ref()).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
@@ -306,7 +288,40 @@ impl WorkspaceSigningIdentityService {
})
}
fn select_signing_key(
&self,
workspace_id: &str,
) -> Result<(
WorkspaceSigningIdentityRecord,
ring::signature::Ed25519KeyPair,
)> {
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" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let signing_key = self.validated_signing_key(&identity)?;
Ok((identity, signing_key))
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
self.validated_signing_key(identity).map(|_| ())
}
fn validated_signing_key(
&self,
identity: &WorkspaceSigningIdentityRecord,
) -> Result<ring::signature::Ed25519KeyPair> {
let material = self
.materials
.load(&identity.private_material_ref)?
@@ -316,8 +331,8 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material is missing",
)
})?;
let public_key =
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
let signing_key = material.signing_key(&identity.workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
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())
@@ -327,7 +342,7 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material does not match public metadata",
));
}
Ok(())
Ok(signing_key)
}
}
@@ -843,6 +858,14 @@ mod tests {
loads: std::sync::atomic::AtomicUsize::new(0),
}),
);
let selected_signature = swapping_service.sign("workspace-1", payload).unwrap();
ring::signature::UnparsedPublicKey::new(
&ring::signature::ED25519,
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap(),
)
.verify(payload, &selected_signature)
.unwrap();
assert!(matches!(
swapping_service.sign("workspace-1", payload),
Err(Error::WorkspaceSigningIdentity { ref code, .. })