fix: reconcile approved compaction result with develop updates
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_key_cutover (
|
||||
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||||
created_at_seconds INTEGER NOT NULL, created_at_subsec_nanos INTEGER NOT NULL,
|
||||
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
|
||||
)?;
|
||||
for (table, kind, id, fingerprint) in [
|
||||
@@ -68,15 +69,20 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
|
||||
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.query_map(
|
||||
params![workspace, kind, resource, legacy_counter, created],
|
||||
|r| r.get::<_, String>(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if matches.len() != 1 || matches[0].is_empty() {
|
||||
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
|
||||
}
|
||||
let created_time = frozen_repository_timestamp(
|
||||
&created,
|
||||
&format!("{table}.created_at (workspace {workspace}, resource {resource})"),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace,
|
||||
kind,
|
||||
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
legacy_counter,
|
||||
matches[0],
|
||||
fingerprint,
|
||||
created
|
||||
created_time.timestamp(),
|
||||
created_time.timestamp_subsec_nanos()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Candidate copying uses an INNER JOIN: reject corrupt source rows before
|
||||
// they can disappear and evade the destination foreign-key check.
|
||||
let orphaned_candidates: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workdir_create_credential_candidates c
|
||||
LEFT JOIN workdir_create_operations o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
|
||||
WHERE o.operation_id IS NULL)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if orphaned_candidates {
|
||||
return Err(Error::Store(
|
||||
"Repository key cutover found credential candidates without a Workdir create operation".into(),
|
||||
));
|
||||
}
|
||||
// Current rows must agree with their pointed-to historical key, not merely
|
||||
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
|
||||
let inconsistent: bool = tx.query_row("SELECT EXISTS (
|
||||
@@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
|
||||
secrets
|
||||
for (
|
||||
workspace,
|
||||
id,
|
||||
legacy_counter,
|
||||
purpose,
|
||||
algorithm,
|
||||
nonce,
|
||||
ciphertext,
|
||||
created,
|
||||
operation,
|
||||
) in secrets
|
||||
{
|
||||
if algorithm != "aes-256-gcm-v1" {
|
||||
return Err(Error::Store(
|
||||
@@ -233,8 +263,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
|
||||
WHERE o.state='succeeded'
|
||||
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
|
||||
INSERT INTO repository_secret_legacy_receipts
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));")?;
|
||||
prepare_repository_cutover_times(&tx)?;
|
||||
tx.execute_batch("INSERT INTO repository_secret_legacy_receipts
|
||||
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
|
||||
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
|
||||
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
|
||||
@@ -244,8 +275,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
|
||||
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
|
||||
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
|
||||
JOIN repository_cutover_times t ON t.kind='receipt' AND t.workspace_id=e.workspace_id AND t.operation_id=e.operation_id
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
|
||||
AND m.created_at<=e.created_at
|
||||
AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
|
||||
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
|
||||
INSERT INTO workdir_create_operations
|
||||
@@ -257,12 +289,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
|
||||
FROM workdir_create_operations_v86 o
|
||||
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
|
||||
LEFT JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND (c.created_at_seconds,c.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND (h.created_at_seconds,h.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos);
|
||||
INSERT INTO workdir_create_credential_candidates
|
||||
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
|
||||
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
|
||||
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
|
||||
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
|
||||
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
|
||||
@@ -313,7 +347,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
] {
|
||||
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
|
||||
}
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover;
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover; DROP TABLE repository_cutover_times;
|
||||
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
|
||||
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
|
||||
@@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
result.and(restore)
|
||||
}
|
||||
|
||||
// Schema 86 used RFC3339 for Repository secrets and epoch-millisecond text for
|
||||
// registry operations. Compare typed instants, never their persisted spelling.
|
||||
fn frozen_repository_timestamp(
|
||||
value: &str,
|
||||
context: &str,
|
||||
) -> Result<chrono::DateTime<chrono::Utc>> {
|
||||
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) {
|
||||
return Ok(timestamp.with_timezone(&chrono::Utc));
|
||||
}
|
||||
if let Ok(millis) = value.parse::<i64>()
|
||||
&& let Some(timestamp) = chrono::DateTime::from_timestamp_millis(millis)
|
||||
{
|
||||
return Ok(timestamp);
|
||||
}
|
||||
Err(Error::Store(format!(
|
||||
"Repository key migration found invalid timestamp in {context}"
|
||||
)))
|
||||
}
|
||||
|
||||
fn prepare_repository_cutover_times(tx: &rusqlite::Transaction<'_>) -> Result<()> {
|
||||
// These temporary normalized values never rewrite audit history or the
|
||||
// original timestamps used to identify receipts and encrypted envelopes.
|
||||
// Seconds + nanoseconds also preserve sub-millisecond ordering and offsets.
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_cutover_times (
|
||||
kind TEXT NOT NULL, workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, subsec_nanos INTEGER NOT NULL,
|
||||
PRIMARY KEY(kind,workspace_id,operation_id));",
|
||||
)?;
|
||||
for (kind, context, query) in [
|
||||
(
|
||||
"receipt",
|
||||
"repository_secret_operations.created_at",
|
||||
"SELECT workspace_id,operation_id,created_at FROM repository_secret_operations_v86",
|
||||
),
|
||||
(
|
||||
"workdir",
|
||||
"workdir_create_operations.updated_at",
|
||||
"SELECT o.workspace_id,o.operation_id,o.updated_at FROM workdir_create_operations_v86 o
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a
|
||||
WHERE a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR
|
||||
EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c
|
||||
WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id))",
|
||||
),
|
||||
] {
|
||||
let rows = tx
|
||||
.prepare(query)?
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (workspace, operation, value) in rows {
|
||||
let timestamp = frozen_repository_timestamp(
|
||||
&value,
|
||||
&format!("{context} (workspace {workspace}, operation {operation})"),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO repository_cutover_times VALUES(?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
kind,
|
||||
workspace,
|
||||
operation,
|
||||
timestamp.timestamp(),
|
||||
timestamp.timestamp_subsec_nanos()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Refuse missing or newer/reused keys with a domain error before a NULL
|
||||
// fingerprint reaches a NOT NULL column. Archived successes are excluded:
|
||||
// they retain opaque history, not authority to retry with a current key.
|
||||
let missing: Option<(String, String)> = tx.query_row(
|
||||
"SELECT r.workspace_id,r.operation_id FROM (
|
||||
SELECT workspace_id,operation_id,'credential' AS kind,credential_id AS resource_id,credential_revision AS legacy_counter
|
||||
FROM workdir_create_operations_v86 WHERE credential_id IS NOT NULL
|
||||
UNION
|
||||
SELECT workspace_id,operation_id,'host_trust',host_trust_id,host_trust_revision
|
||||
FROM workdir_create_operations_v86 WHERE host_trust_id IS NOT NULL
|
||||
UNION
|
||||
SELECT workspace_id,operation_id,'credential',credential_id,credential_revision
|
||||
FROM workdir_create_credential_candidates_v86
|
||||
) r
|
||||
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=r.workspace_id AND t.operation_id=r.operation_id
|
||||
LEFT JOIN repository_key_cutover k ON k.workspace_id=r.workspace_id AND k.kind=r.kind
|
||||
AND k.resource_id=r.resource_id AND k.legacy_counter=r.legacy_counter
|
||||
AND (k.created_at_seconds,k.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
WHERE k.operation_id IS NULL LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?;
|
||||
if let Some((workspace, operation)) = missing {
|
||||
return Err(Error::Store(format!(
|
||||
"Workdir SSH snapshot is missing key identity evidence at or before its timestamp (workspace {workspace}, operation {operation})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
|
||||
// are never read by credential lookup, lease construction, or a live CAS.
|
||||
fn frozen_repository_json_rows(
|
||||
|
||||
@@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
||||
let end = start + frozen[start..].find(end).unwrap();
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
}
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision)
|
||||
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7);
|
||||
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','key',7);
|
||||
INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7);
|
||||
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7);
|
||||
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
|
||||
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'2026-01-01T00:00:00.000Z')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
// Exercise the actual persisted formats, including failed creates whose SSH
|
||||
// snapshot remains retry authority. The encrypted fixture uses the real old AAD.
|
||||
fn legacy_ssh_snapshot_times(conn: &Connection, key_created: &str, workdir_updated: &str) {
|
||||
conn.execute(
|
||||
"UPDATE repository_secret_operations SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_secret_audit_events SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credentials SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE server_secret_versions SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space','create-host','intent','host_trust','host',3,?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_created','host',3,'actor',?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','host-key','host-fingerprint',3,?1,?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'example.test',22,'ssh-ed25519','host-key','host-fingerprint',?1)", [key_created]).unwrap();
|
||||
conn.execute("UPDATE workdir_create_operations SET state='failed',failure='runtime_workdir_create_failed',host_trust_id='host',host_trust_revision=3,repository_access_mode='read_only',updated_at=?1", [workdir_updated]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_compares_mixed_timestamp_formats_as_instants() {
|
||||
for (key_created, workdir_updated) in [
|
||||
("2026-01-01T00:00:00.000Z", "1767225600000"),
|
||||
("2026-01-01T00:00:00.000Z", "1767225600001"),
|
||||
("2026-01-01T09:00:00.000+09:00", "2026-01-01T00:00:00Z"),
|
||||
(
|
||||
"2026-01-01T00:00:00.000000001Z",
|
||||
"2026-01-01T00:00:00.000000002Z",
|
||||
),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, key_created, workdir_updated);
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
let snapshot: (String, String, String, String) = conn.query_row(
|
||||
"SELECT state,credential_fingerprint,host_trust_fingerprint,updated_at FROM workdir_create_operations",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
snapshot,
|
||||
(
|
||||
"failed".into(),
|
||||
"fingerprint".into(),
|
||||
"host-fingerprint".into(),
|
||||
workdir_updated.into()
|
||||
)
|
||||
);
|
||||
let retained: (String, String, String) = conn.query_row(
|
||||
"SELECT c.credential_fingerprint,r.credential_fingerprint,r.credential_operation_id
|
||||
FROM workdir_create_credential_candidates c JOIN workdir_create_credential_retentions r
|
||||
ON r.workspace_id=c.workspace_id AND r.operation_id=c.operation_id AND r.ordinal=c.ordinal",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
retained,
|
||||
(
|
||||
"fingerprint".into(),
|
||||
"fingerprint".into(),
|
||||
"create-key".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT created_at FROM repository_ssh_credential_keys",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
key_created
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.unwrap()
|
||||
.exists([])
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_timestamp_cutover_rolls_back(conn: &Connection, expected_context: &str) {
|
||||
let before =
|
||||
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap();
|
||||
let ciphertext: Vec<u8> = conn
|
||||
.query_row(
|
||||
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_repository_keys_v86_to_v87(conn)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected_context), "{error}");
|
||||
assert!(error.contains("timestamp"), "{error}");
|
||||
assert_eq!(current_schema_version(conn).unwrap(), 86);
|
||||
assert_eq!(
|
||||
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
|
||||
[],
|
||||
|r| r.get::<_, Vec<u8>>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
ciphertext
|
||||
);
|
||||
assert!(table_exists(conn, "repository_ssh_credential_revisions").unwrap());
|
||||
assert!(!table_exists(conn, "repository_ssh_credential_keys").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM workdir_create_credential_candidates",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM workdir_create_credential_revision_retentions",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_temp_master WHERE name IN ('repository_key_cutover','repository_cutover_times','workdir_terminal_archive_cutover')", [], |r| r.get::<_,i64>(0)).unwrap(), 0);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_rejects_newer_reused_snapshot_keys_without_committing_authority() {
|
||||
for source in ["credential", "host_trust", "candidate"] {
|
||||
for later in ["2026-01-01T00:00:00.001Z", "2026-01-01T00:00:00.000000001Z"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
|
||||
if source == "host_trust" {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
|
||||
[later],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [later]).unwrap();
|
||||
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='host_trust_created'", [later]).unwrap();
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[later],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [later]).unwrap();
|
||||
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='credential_created'", [later]).unwrap();
|
||||
if source == "candidate" {
|
||||
conn.execute("UPDATE workdir_create_operations SET credential_id=NULL,credential_revision=NULL", []).unwrap();
|
||||
}
|
||||
}
|
||||
assert_timestamp_cutover_rolls_back(&conn, "Workdir SSH snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_invalid_authority_timestamps_roll_back_with_source_context() {
|
||||
for (source, invalid) in [
|
||||
("workdir_create_operations.updated_at", "not-a-date"),
|
||||
(
|
||||
"workdir_create_operations.updated_at",
|
||||
"9223372036854775807",
|
||||
),
|
||||
(
|
||||
"repository_ssh_credential_revisions.created_at",
|
||||
"not-a-date",
|
||||
),
|
||||
(
|
||||
"repository_ssh_host_trust_revisions.created_at",
|
||||
"not-a-date",
|
||||
),
|
||||
("repository_secret_operations.created_at", "not-a-date"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
|
||||
match source {
|
||||
"workdir_create_operations.updated_at" => {
|
||||
conn.execute(
|
||||
"UPDATE workdir_create_operations SET updated_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"repository_ssh_credential_revisions.created_at" => {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [invalid]).unwrap();
|
||||
}
|
||||
"repository_ssh_host_trust_revisions.created_at" => {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [invalid]).unwrap();
|
||||
}
|
||||
"repository_secret_operations.created_at" => {
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','gone',1,?1)", [invalid]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','gone',1,'actor',?1)", [invalid]).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_timestamp_cutover_rolls_back(&conn, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO workdir_create_credential_candidates VALUES('space','missing-parent',0,'primary','key',7);
|
||||
PRAGMA foreign_keys=ON;").unwrap();
|
||||
let before = frozen_repository_json_rows(
|
||||
&conn,
|
||||
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("credential candidates without a Workdir create operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
assert_eq!(
|
||||
frozen_repository_json_rows(
|
||||
&conn,
|
||||
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
|
||||
&[]
|
||||
)
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(table_exists(&conn, "repository_ssh_credential_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "repository_ssh_credential_keys").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||
.unwrap();
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE workdir_create_operations SET updated_at='later'",
|
||||
"UPDATE workdir_create_operations SET updated_at='2026-01-04T00:00:00.000Z'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err());
|
||||
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')", []).is_err());
|
||||
assert!(
|
||||
!conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
@@ -274,14 +563,14 @@ fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created');
|
||||
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated');
|
||||
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'2026-01-03T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','2026-01-03T00:00:00.000Z');
|
||||
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
|
||||
@@ -342,8 +631,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
|
||||
let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
|
||||
conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap();
|
||||
conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions;
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'2026-01-03T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','2026-01-03T00:00:00.000Z');
|
||||
DELETE FROM repository_ssh_credentials;").unwrap();
|
||||
let original =
|
||||
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
|
||||
@@ -666,7 +955,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"credential",
|
||||
"key",
|
||||
1,
|
||||
"2026-01-01",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"credential_created",
|
||||
),
|
||||
(
|
||||
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"credential",
|
||||
"key",
|
||||
2,
|
||||
"2026-01-02",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"credential_rotated",
|
||||
),
|
||||
(
|
||||
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"host_trust",
|
||||
"host",
|
||||
1,
|
||||
"2026-01-01",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"host_trust_created",
|
||||
),
|
||||
(
|
||||
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"host_trust",
|
||||
"host",
|
||||
2,
|
||||
"2026-01-02",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"host_trust_rotated",
|
||||
),
|
||||
] {
|
||||
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",[&next_fp]).unwrap();
|
||||
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
for (legacy_counter, fingerprint, private, timestamp) in [
|
||||
(1u64, &old_fp, &old_private, "2026-01-01"),
|
||||
(2, &next_fp, &next_private, "2026-01-02"),
|
||||
(1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
|
||||
(2, &next_fp, &next_private, "2026-01-02T00:00:00.000Z"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
|
||||
let nonce = [legacy_counter as u8; 12];
|
||||
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",params![public,old_fp]).unwrap();
|
||||
for (legacy_counter, hostname, timestamp) in [
|
||||
(1, "old.example.test", "2026-01-01"),
|
||||
(2, "new.example.test", "2026-01-02"),
|
||||
(1, "old.example.test", "2026-01-01T00:00:00.000Z"),
|
||||
(2, "new.example.test", "2026-01-02T00:00:00.000Z"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
|
||||
}
|
||||
@@ -884,8 +1173,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
|
||||
// The old deleted ID has been recreated using the same ordinal. Its earlier
|
||||
// delete receipt must remain history, not acquire proof from this new key.
|
||||
conn.execute_batch("DELETE FROM workdir_create_operations;
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01T00:00:00.000Z');").unwrap();
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
||||
}
|
||||
"inconsistent_retention" => {
|
||||
// A different valid retained key passes the ordinal-only candidate FK.
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later');
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'2026-01-04T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','2026-01-04T00:00:00.000Z');
|
||||
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
|
||||
}
|
||||
"missing_audit_receipt" => {
|
||||
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
||||
.unwrap();
|
||||
}
|
||||
"missing_host_receipt" => {
|
||||
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','2026-01-01T00:00:00.000Z');").unwrap();
|
||||
}
|
||||
"foreign_key_violation" => {
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created');
|
||||
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'2026-01-01T00:00:00.000Z');
|
||||
PRAGMA foreign_keys=ON;").unwrap();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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(¤t)
|
||||
.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(¤t)
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','2026-01-01T00:00:00.000Z');").unwrap();
|
||||
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
let mut ciphertext = b"private bytes".to_vec();
|
||||
key.seal_in_place_append_tag(
|
||||
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'2026-01-01T00:00:00.000Z')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
drop(conn);
|
||||
let master_key = dir.path().join("repository-secrets.master-key");
|
||||
std::fs::write(&master_key, [42; 32]).unwrap();
|
||||
|
||||
@@ -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, .. })
|
||||
|
||||
Reference in New Issue
Block a user