diff --git a/crates/worker-runtime/src/auth.rs b/crates/worker-runtime/src/auth.rs index 9e7a89484..d59880de2 100644 --- a/crates/worker-runtime/src/auth.rs +++ b/crates/worker-runtime/src/auth.rs @@ -42,7 +42,11 @@ pub enum RuntimeAuthError { #[error("runtime request proof contains an invalid `{0}` claim")] InvalidClaim(&'static str), #[error("runtime request proof does not match the HTTP request")] - ClaimMismatch, + ClaimMismatch(RuntimeRequestClaim), + #[error("runtime request proof was issued in the future")] + RequestIssuedInFuture { iat: i64, exp: i64, now: i64 }, + #[error("runtime request proof has expired")] + RequestExpired { iat: i64, exp: i64, now: i64 }, #[error("unknown token issuer `{0}`")] UnknownIssuer(String), #[error("invalid token signature")] @@ -171,6 +175,19 @@ pub struct RuntimeAuthContext { pub expires_at: u64, } +/// The first mismatching signed claim, in verification order. No input values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeRequestClaim { + Issuer, + Audience, + Workspace, + Worker, + Permission, + Method, + Path, + BodyDigest, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeRequestSourceClaims { pub iss: String, @@ -293,19 +310,54 @@ pub fn verify_runtime_request_source( public_key, )?; let claims = signed.claims; - if claims.iss != expected.identity_id - || claims.aud != expected.audience - || claims.workspace_id != expected.workspace_id - || claims.worker_id.as_deref() != expected.worker_id - || claims.permission != expected.permission - || claims.method != expected.method - || claims.path != expected.path - || claims.body_digest != expected.body_digest - { - return Err(RuntimeAuthError::ClaimMismatch); + for (matches, claim) in [ + ( + claims.iss == expected.identity_id, + RuntimeRequestClaim::Issuer, + ), + ( + claims.aud == expected.audience, + RuntimeRequestClaim::Audience, + ), + ( + claims.workspace_id == expected.workspace_id, + RuntimeRequestClaim::Workspace, + ), + ( + claims.worker_id.as_deref() == expected.worker_id, + RuntimeRequestClaim::Worker, + ), + ( + claims.permission == expected.permission, + RuntimeRequestClaim::Permission, + ), + ( + claims.method == expected.method, + RuntimeRequestClaim::Method, + ), + (claims.path == expected.path, RuntimeRequestClaim::Path), + ( + claims.body_digest == expected.body_digest, + RuntimeRequestClaim::BodyDigest, + ), + ] { + if !matches { + return Err(RuntimeAuthError::ClaimMismatch(claim)); + } } - if claims.iat > expected.now_unix || claims.exp < expected.now_unix { - return Err(RuntimeAuthError::Expired); + if claims.iat > expected.now_unix { + return Err(RuntimeAuthError::RequestIssuedInFuture { + iat: claims.iat, + exp: claims.exp, + now: expected.now_unix, + }); + } + if claims.exp < expected.now_unix { + return Err(RuntimeAuthError::RequestExpired { + iat: claims.iat, + exp: claims.exp, + now: expected.now_unix, + }); } Ok(claims) } @@ -705,7 +757,9 @@ mod tests { }; assert!(matches!( verify_runtime_request_source(&proof, &trusted.public_key, &changed_body), - Err(RuntimeAuthError::ClaimMismatch) + Err(RuntimeAuthError::ClaimMismatch( + RuntimeRequestClaim::BodyDigest + )) )); let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); assert!(matches!( @@ -714,6 +768,183 @@ mod tests { )); } + #[test] + fn runtime_request_proof_preserves_inclusive_time_bounds_and_reports_exact_rejections() { + let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + let signer = RuntimeRequestSourceSigner::from_identity(&runtime); + let proof = signer + .issue( + "server", + "workspace", + None, + "permission", + "POST", + "/request", + b"{}", + 90, + 10, + ) + .unwrap(); + let digest = request_body_digest(b"{}"); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-main", + audience: "server", + workspace_id: "workspace", + worker_id: None, + permission: "permission", + method: "POST", + path: "/request", + body_digest: &digest, + now_unix: 90, + }; + for now in [90, 99, 100] { + assert!( + verify_runtime_request_source( + &proof, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + now_unix: now, + ..expected.clone() + } + ) + .is_ok() + ); + } + for (now, future) in [(89, true), (101, false)] { + let error = verify_runtime_request_source( + &proof, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + now_unix: now, + ..expected.clone() + }, + ) + .unwrap_err(); + match error { + RuntimeAuthError::RequestIssuedInFuture { + iat, + exp, + now: verified, + } if future => assert_eq!((iat, exp, verified), (90, 100, now)), + RuntimeAuthError::RequestExpired { + iat, + exp, + now: verified, + } if !future => assert_eq!((iat, exp, verified), (90, 100, now)), + other => panic!("unexpected rejection: {other:?}"), + } + } + for (claim, expectation) in [ + ( + RuntimeRequestClaim::Issuer, + RuntimeRequestSourceExpectation { + identity_id: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Audience, + RuntimeRequestSourceExpectation { + audience: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Workspace, + RuntimeRequestSourceExpectation { + workspace_id: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Worker, + RuntimeRequestSourceExpectation { + worker_id: Some("other"), + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Permission, + RuntimeRequestSourceExpectation { + permission: "other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Method, + RuntimeRequestSourceExpectation { + method: "GET", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::Path, + RuntimeRequestSourceExpectation { + path: "/other", + ..expected.clone() + }, + ), + ( + RuntimeRequestClaim::BodyDigest, + RuntimeRequestSourceExpectation { + body_digest: "other", + ..expected.clone() + }, + ), + ] { + assert!( + matches!(verify_runtime_request_source(&proof, &runtime.public_key, &expectation), Err(RuntimeAuthError::ClaimMismatch(actual)) if actual == claim) + ); + } + assert!(matches!( + verify_runtime_request_source( + &proof, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + audience: "other", + now_unix: 101, + ..expected.clone() + } + ), + Err(RuntimeAuthError::ClaimMismatch( + RuntimeRequestClaim::Audience + )) + )); + let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); + assert!(matches!( + verify_runtime_request_source( + &proof, + &spoofed.public_key, + &RuntimeRequestSourceExpectation { + now_unix: 101, + ..expected.clone() + } + ), + Err(RuntimeAuthError::InvalidSignature) + )); + let mut claims = decode_runtime_request_source_claims(&proof).unwrap(); + claims.iat = 101; + claims.exp = 89; + let contradictory = sign_json_token( + RUNTIME_REQUEST_SOURCE_PROOF_PREFIX, + RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX, + &runtime.signing_key().unwrap(), + &claims, + ) + .unwrap(); + assert!(matches!( + verify_runtime_request_source( + &contradictory, + &runtime.public_key, + &RuntimeRequestSourceExpectation { + now_unix: 100, + ..expected.clone() + } + ), + Err(RuntimeAuthError::RequestIssuedInFuture { .. }) + )); + } + #[test] fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() { let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap(); @@ -749,7 +980,9 @@ mod tests { }; assert!(matches!( verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace), - Err(RuntimeAuthError::ClaimMismatch) + Err(RuntimeAuthError::ClaimMismatch( + RuntimeRequestClaim::Workspace + )) )); let expired = RuntimeRequestSourceExpectation { now_unix: 101, @@ -757,7 +990,11 @@ mod tests { }; assert!(matches!( verify_runtime_request_source(&proof, &runtime.public_key, &expired), - Err(RuntimeAuthError::Expired) + Err(RuntimeAuthError::RequestExpired { + iat: 90, + exp: 100, + now: 101 + }) )); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 374ddd797..2ca6c223b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2632,34 +2632,30 @@ fn runtime_request_proof_rejection( method: &str, path: &str, workspace_id: &str, - error: &crate::worker_source::WorkerMutationSourceProofError, + error: &crate::worker_source::RuntimeRequestProofError, ) -> Response { - use crate::worker_source::WorkerMutationSourceProofError; - - // Do not format the error itself: Authority and MissingPermission can carry - // internal or request-derived strings. Only emit allowlisted reason codes. - let reason = match error { - WorkerMutationSourceProofError::Missing => "missing", - WorkerMutationSourceProofError::Invalid => "invalid", - WorkerMutationSourceProofError::WrongAudience => "wrong_audience", - WorkerMutationSourceProofError::WrongWorkspace => "wrong_workspace", - WorkerMutationSourceProofError::WrongActor => "wrong_actor", - WorkerMutationSourceProofError::MissingPermission(_) => "missing_permission", - WorkerMutationSourceProofError::Expired => "expired", - WorkerMutationSourceProofError::RevokedRuntimeTrust => "runtime_trust_missing_or_revoked", - WorkerMutationSourceProofError::Replay => "replay", - WorkerMutationSourceProofError::WorkerCatalogMembership => "worker_catalog_membership", - WorkerMutationSourceProofError::Authority(_) => "authority_unavailable", - }; + // Never format an error or raw claims. IDs are fixed-length fingerprints; + // verification labels describe proof-check progress, not authorization. let path = path.split('?').next().unwrap_or(path); + let log_path = bounded_auth_log_value(path, 512); + let method = bounded_auth_log_value(method, 32); + let workspace_id = bounded_auth_log_value(workspace_id, 128); + let request_id = uuid::Uuid::now_v7().to_string(); tracing::warn!( target: "yoi::auth", event = "runtime_request_proof_rejected", - method, - path, - workspace_id, + method, path = log_path, workspace_id, request_id, status = 401, - reason, + reason = error.reason, + proof_verification = error.verification, + claimed_runtime_id_hash = error.runtime_id_hash.as_deref(), + claimed_worker_id_hash = error.worker_id_hash.as_deref(), + claimed_token_id_hash = error.token_id_hash.as_deref(), + issued_at_unix = error.iat, + expires_at_unix = error.exp, + verified_at_unix = error.now, + time_delta_seconds = error.time_delta_seconds, + time_tolerance_seconds = error.time_delta_seconds.map(|_| 0_u64), "Runtime request proof rejected", ); repository_api_rejection( @@ -2669,6 +2665,17 @@ fn runtime_request_proof_rejection( ) } +fn bounded_auth_log_value(value: &str, max_bytes: usize) -> String { + let mut result = String::new(); + for ch in value.chars().filter(|ch| !ch.is_control()) { + if result.len() + ch.len_utf8() > max_bytes { + break; + } + result.push(ch); + } + result +} + /// Authored configuration editors are user-facing authority, not a generic /// Runtime workspace.request capability. Worker configuration access is only /// through the grant- and connection-bound WIP adapter. Keep runtime projections @@ -55527,7 +55534,10 @@ mod tests { .await; assert!(matches!( result, - Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust) + Err(crate::worker_source::RuntimeRequestProofError { + reason: "runtime_trust_missing_or_revoked", + .. + }) )); } @@ -55603,7 +55613,10 @@ mod tests { .await; assert!(matches!( result, - Err(crate::worker_source::WorkerMutationSourceProofError::WorkerCatalogMembership) + Err(crate::worker_source::RuntimeRequestProofError { + reason: "worker_catalog_membership", + .. + }) )); } diff --git a/crates/workspace-server/src/server/tests/auth_logging_tests.rs b/crates/workspace-server/src/server/tests/auth_logging_tests.rs index 24a61fc03..b4b51d7bc 100644 --- a/crates/workspace-server/src/server/tests/auth_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/auth_logging_tests.rs @@ -13,62 +13,58 @@ impl Write for CapturedAuthLog { self.0.lock().unwrap().extend_from_slice(bytes); Ok(bytes.len()) } - fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } -#[test] -fn runtime_proof_diagnostics_redact_error_payloads() { - use crate::worker_source::WorkerMutationSourceProofError; - - for (error, reason) in [ - ( - WorkerMutationSourceProofError::Authority("private-database-path".to_string()), - "authority_unavailable", - ), - ( - WorkerMutationSourceProofError::MissingPermission("private-permission".to_string()), - "missing_permission", - ), - ] { - let logs = CapturedAuthLog::default(); - let writer = logs.clone(); - let subscriber = tracing_subscriber::fmt() +impl CapturedAuthLog { + fn subscriber(&self) -> impl tracing::Subscriber + Send + Sync + use<> { + let writer = self.clone(); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) .without_time() .with_ansi(false) .json() .flatten_event(true) .with_writer(move || writer.clone()) - .finish(); - tracing::subscriber::with_default(subscriber, || { - let response = runtime_request_proof_rejection( - "POST", - "/api/w/workspace/workers/self/workdir-session/operations?secret=query-secret", - "workspace", - &error, - ); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - }); - let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap(); - let event: Value = serde_json::from_str(text.trim()).unwrap(); - assert_eq!(event["reason"], reason); - assert!(!text.contains("private-"), "auth log leaked error details"); - assert!( - !text.contains("query-secret"), - "auth log leaked query values" + .finish() + } + fn event(&self) -> (Value, String) { + let text = String::from_utf8(self.0.lock().unwrap().clone()).unwrap(); + let events: Vec = text + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|event| event["event"] == "runtime_request_proof_rejected") + .collect(); + assert_eq!( + events.len(), + 1, + "missing or duplicated auth diagnostic: {text}" ); + (events[0].clone(), text) } } #[tokio::test] -async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_routers() { +async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_both_routers() { let workspace = tempfile::tempdir().unwrap(); let mut api = test_api(workspace.path()).await; let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); configure_runtime_request_auth(&mut api, &identity, "runtime-test"); - let signer = RuntimeRequestSourceSigner::from_identity(&identity); + let revoked = RuntimeIdentityMaterial::generate("revoked-runtime-secret").unwrap(); + configure_runtime_request_auth(&mut api, &revoked, "revoked-runtime-secret"); + SqliteWorkspaceStore::open(&api.config.database_path) + .unwrap() + .revoke_workspace_runtime_binding( + TEST_WORKSPACE_ID, + "revoked-runtime-secret", + "2026-10-08T00:00:00Z", + ) + .unwrap(); + let spoofed = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + let unbound_id = "unverified-runtime-secret".repeat(1000); + let unbound = RuntimeIdentityMaterial::generate(&unbound_id).unwrap(); let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations"); let target = format!("{path}?access_token=query-secret"); let body = "body-secret"; @@ -81,24 +77,94 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout ]; for app in apps { - for reason in ["expired", "replay", "invalid"] { + for (case, verification) in [ + ("expired", "signature_verified"), + ("issued_in_future", "signature_verified"), + ("replay", "request_verified"), + ("invalid_format", "unverified"), + ("invalid_encoding", "unverified"), + ("malformed_claims", "unverified"), + ("invalid_signature", "unverified"), + ("tampered_signature", "unverified"), + ("revoked_runtime", "unverified"), + ("runtime_trust_missing_or_revoked", "unverified"), + ("audience_mismatch", "signature_verified"), + ("workspace_mismatch", "signature_verified"), + ("permission_mismatch", "signature_verified"), + ("method_mismatch", "signature_verified"), + ("path_mismatch", "signature_verified"), + ("body_digest_mismatch", "signature_verified"), + ("worker_catalog_membership", "request_verified"), + ] { + let reason = match case { + "tampered_signature" => "invalid_signature", + "revoked_runtime" => "runtime_trust_missing_or_revoked", + other => other, + }; let now = i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(); - let proof = if reason == "invalid" { - "invalid-proof-secret".to_string() - } else { - signer + let signing_identity = match case { + "invalid_signature" => &spoofed, + "revoked_runtime" => &revoked, + "runtime_trust_missing_or_revoked" => &unbound, + _ => &identity, + }; + let proof = match reason { + "invalid_format" => "invalid-proof-secret".repeat(1000), + "invalid_encoding" => "yoi-runtime-request-v1.!.!".to_string(), + "malformed_claims" => "yoi-runtime-request-v1.e30.AA".to_string(), // {} claims + _ => RuntimeRequestSourceSigner::from_identity(signing_identity) .issue( - "server-test", - TEST_WORKSPACE_ID, - None, - WORKSPACE_REQUEST_PERMISSION, - "POST", - &target, - body.as_bytes(), - if reason == "expired" { now - 120 } else { now }, - 30, + if reason == "audience_mismatch" { + "audience-secret" + } else { + "server-test" + }, + if reason == "workspace_mismatch" { + "workspace-secret" + } else { + TEST_WORKSPACE_ID + }, + (reason == "worker_catalog_membership").then_some("worker-secret"), + if reason == "permission_mismatch" { + "permission-secret" + } else { + WORKSPACE_REQUEST_PERMISSION + }, + if reason == "method_mismatch" { + "GET" + } else { + "POST" + }, + if reason == "path_mismatch" { + "/path-secret" + } else { + &target + }, + if reason == "body_digest_mismatch" { + b"different-body-secret" + } else { + body.as_bytes() + }, + match reason { + "expired" => i64::MIN, + "issued_in_future" => i64::MAX, + _ => now, + }, + if matches!(reason, "expired" | "issued_in_future") { + 0 + } else { + 30 + }, ) - .unwrap() + .unwrap(), + }; + let proof = if case == "tampered_signature" { + let (signed, signature) = proof.rsplit_once('.').unwrap(); + // Flip a significant signature byte, retaining valid base64url. + let first = if signature.starts_with('A') { "B" } else { "A" }; + format!("{signed}.{first}{}", &signature[1..]) + } else { + proof }; if reason == "replay" { crate::worker_source::verify_runtime_request_source_proof( @@ -114,15 +180,6 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout .unwrap(); } let logs = CapturedAuthLog::default(); - let writer = logs.clone(); - let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::WARN) - .without_time() - .with_ansi(false) - .json() - .flatten_event(true) - .with_writer(move || writer.clone()) - .finish(); let response = app .clone() .oneshot( @@ -135,39 +192,206 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout .body(Body::from(body)) .unwrap(), ) - .with_subscriber(subscriber) + .with_subscriber(logs.subscriber()) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{reason}"); let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); assert!(response_body.is_empty(), "{response_body:?}"); - - let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap(); - let events: Vec = text - .lines() - .map(|line| serde_json::from_str::(line).unwrap()) - .filter(|event| event["event"] == "runtime_request_proof_rejected") - .collect(); - assert_eq!( - events.len(), - 1, - "missing or duplicated auth diagnostic: {text}" - ); - let event = &events[0]; + let (event, text) = logs.event(); assert_eq!(event["reason"], reason); + assert_eq!(event["proof_verification"], verification); assert_eq!(event["status"], 401); assert_eq!(event["method"], "POST"); assert_eq!(event["path"], path); assert_eq!(event["workspace_id"], TEST_WORKSPACE_ID); + assert!(uuid::Uuid::parse_str(event["request_id"].as_str().unwrap()).is_ok()); + if verification == "unverified" + && matches!( + reason, + "invalid_format" | "invalid_encoding" | "malformed_claims" + ) + { + assert!(event.get("claimed_runtime_id_hash").is_none()); + assert!(event.get("claimed_token_id_hash").is_none()); + } else { + let claims = + worker_runtime::auth::decode_runtime_request_source_claims(&proof).unwrap(); + assert_eq!( + event["claimed_runtime_id_hash"], + crate::worker_source::diagnostic_id_hash(&claims.iss) + ); + assert_eq!( + event["claimed_token_id_hash"], + crate::worker_source::diagnostic_id_hash(&claims.jti) + ); + if let Some(worker) = &claims.worker_id { + assert_eq!( + event["claimed_worker_id_hash"], + crate::worker_source::diagnostic_id_hash(worker) + ); + } + } + if matches!(reason, "expired" | "issued_in_future") { + let bound = if reason == "expired" { + i64::MIN + } else { + i64::MAX + }; + assert_eq!(event["issued_at_unix"], bound); + assert_eq!(event["expires_at_unix"], bound); + let verified = event["verified_at_unix"].as_i64().unwrap(); + assert_eq!( + event["time_delta_seconds"].as_u64(), + Some(bound.abs_diff(verified)) + ); + assert_eq!(event["time_tolerance_seconds"], 0); + } else { + assert!(event.get("time_delta_seconds").is_none()); + assert!(event.get("issued_at_unix").is_none()); + } for secret in [ &proof, + &identity.private_key, + &spoofed.private_key, + &unbound_id, "access_token", "query-secret", body, "bearer-secret", + "audience-secret", + "workspace-secret", + "permission-secret", + "path-secret", + "worker-secret", + "revoked-runtime-secret", + &revoked.private_key, ] { - assert!(!text.contains(secret), "auth log leaked request data"); + assert!( + !text.contains(secret), + "auth log leaked request data: {reason}" + ); } + assert!(text.len() < 2200, "unbounded diagnostic: {}", text.len()); + } + } +} + +#[tokio::test] +async fn runtime_proof_setup_failure_logs_no_internal_error_and_bounds_request_metadata() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + api.config.backend_base_url = None; + let proof = RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + TEST_WORKSPACE_ID, + None, + WORKSPACE_REQUEST_PERMISSION, + "POST", + "/request", + b"{}", + 90, + 10, + ) + .unwrap(); + let error = crate::worker_source::verify_runtime_request_source_proof( + &api, + &proof, + "private-workspace-secret", + WORKSPACE_REQUEST_PERMISSION, + "POST", + "/request", + "digest", + ) + .await + .unwrap_err(); + let logs = CapturedAuthLog::default(); + let long_path = format!("/{}?secret=query-secret", "é\n".repeat(1000)); + let response = tracing::subscriber::with_default(logs.subscriber(), || { + runtime_request_proof_rejection( + &"M\n".repeat(1000), + &long_path, + &"w\n".repeat(1000), + &error, + ) + }); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .is_empty() + ); + let (event, text) = logs.event(); + assert_eq!(event["reason"], "backend_audience_unavailable"); + assert_eq!(event["proof_verification"], "unverified"); + for (field, limit) in [("method", 32), ("path", 512), ("workspace_id", 128)] { + let value = event[field].as_str().unwrap(); + assert!(value.len() <= limit); + assert!(!value.chars().any(char::is_control)); + } + for secret in [&proof, "private-workspace-secret", "query-secret"] { + assert!(!text.contains(secret)); + } +} + +#[tokio::test] +async fn repository_runtime_proof_401_keeps_generic_envelope_on_both_routers() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/repositories"); + for app in [ + build_router(api.clone()), + workspace_server_router(WorkspaceServerApi::new( + api.config.clone(), + api.store.clone(), + )), + ] { + for issued_at in [i64::MIN, i64::MAX] { + let proof = RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + TEST_WORKSPACE_ID, + None, + WORKSPACE_REQUEST_PERMISSION, + "GET", + &path, + b"", + issued_at, + 0, + ) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&path) + .header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, &proof) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let actual: Value = serde_json::from_slice(&body).unwrap(); + let expected = serde_json::to_value(server_api::RepositoryApiError::new( + 401, + "Unauthorized", + "invalid runtime request proof", + Vec::new(), + )) + .unwrap(); + assert_eq!( + actual, expected, + "auth internals must not expand the external envelope" + ); } } } diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 11f3842ba..036e0fb17 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -139,26 +139,33 @@ impl worker::WorkspaceClient for ConfigRouterClient { Ok(response) } } -fn config_interface(path: &str) -> wip_protocol::InterfaceReference { - // Use the published structured Interface identity, not a model-managed validator. - wip_protocol::InterfaceReference { - scope: path.into(), - name: "yoi.workspace-config/node/v1".into(), - } +fn config_interface(path: &str) -> Value { + // Exact contextual reference published by the current config Object. + json!({"scope": path, "name": "yoi.workspace-config/node/v1"}) } -async fn config_inspect(runtime: &worker::wip::WipRuntime, path: &str) { - let output = runtime.inspect(path.into(), true).await.unwrap(); - let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); +async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) { + inspect_published_interface(runtime, path, &config_interface(path)).await; +} +async fn inspect_published_interface( + runtime: &worker::wip::WipRuntime, + path: &str, + expected: &Value, +) { + let tree = runtime.tree(path.into(), 0, true).await.unwrap(); + let tree: Value = serde_json::from_str(tree.content.as_deref().unwrap()).unwrap(); + assert_eq!( + tree["path"], path, + "Object must come from live Tree: {tree}" + ); + let inspected = runtime.inspect(path.into(), true).await.unwrap(); + let inspected: Value = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap(); assert!( - observed["interfaces"] + inspected["interfaces"] .as_array() .unwrap() .iter() - .any(|interface| { - interface["reference"] - == json!({"scope": path, "name": config_interface(path).name}) - }), - "Interface must come from live Object inspection: {observed}" + .any(|interface| &interface["reference"] == expected), + "interface must come from live path-centered Inspect: {inspected}" ); } async fn config_call( @@ -170,7 +177,10 @@ async fn config_call( runtime .invoke( path.into(), - config_interface(path), + wip_protocol::InterfaceReference { + scope: path.into(), + name: "yoi.workspace-config/node/v1".into(), + }, operation.into(), args, agen::tool::ToolExecutionContext::direct(), @@ -184,17 +194,9 @@ async fn catalog_call( base: &str, operation: &str, ) -> Value { - let interface = json!({"scope": path, "name": base}); - let inspected = runtime.inspect(path.into(), true).await.unwrap(); - let observed: Value = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap(); - assert!( - observed["interfaces"] - .as_array() - .unwrap() - .iter() - .any(|published| published["reference"] == interface), - "{observed}" - ); + // Live catalog publication contextualizes the Interface to this Object. + let reference = json!({"scope": path, "name": base}); + inspect_published_interface(runtime, path, &reference).await; let output = runtime .invoke( path.into(), @@ -380,7 +382,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .connection_id, attached.connection_id ); - config_inspect(&wip, CONTENT_ROOT).await; + config_discover(&wip, CONTENT_ROOT).await; config_call( &wip, CONTENT_ROOT, @@ -390,7 +392,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .await .unwrap(); let path = "/workspace-config/notes/wip.md"; - config_inspect(&wip, path).await; + config_discover(&wip, path).await; assert!( config_call(&wip, path, "read", json!({})) .await @@ -424,7 +426,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im config_call(&wip, path, "write", json!({"content":"replacement note"})) .await .unwrap(); - config_inspect(&wip, CONTENT_ROOT).await; + config_discover(&wip, CONTENT_ROOT).await; config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[ {"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"}, {"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"} @@ -438,7 +440,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .unwrap(); let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap(); assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E"); - config_inspect(&wip, path).await; + config_discover(&wip, path).await; config_call(&wip, path, "delete", json!({})).await.unwrap(); assert!( fixture @@ -451,7 +453,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .get(&config_source::VirtualPath::parse("notes/wip.md").unwrap()) .is_none() ); - config_inspect(&wip, CONTENT_ROOT).await; + config_discover(&wip, CONTENT_ROOT).await; let before = fixture .api .config_store @@ -494,7 +496,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im dispatched + 1, "a lost response cannot trigger automatic retry" ); - config_inspect(&wip, CONTENT_ROOT).await; + config_discover(&wip, CONTENT_ROOT).await; let readonly = workspace_config::attach( &fixture.api, &fixture.worker, @@ -635,7 +637,7 @@ async fn assert_config_production_http_adapter( ) .unwrap(); assert_config_catalog(&wip, &attached).await; - config_inspect(&wip, "/workspace-config/main.dcdl").await; + config_discover(&wip, "/workspace-config/main.dcdl").await; let main_interface = wip .inspect("/workspace-config/main.dcdl".into(), true) .await @@ -654,7 +656,7 @@ async fn assert_config_production_http_adapter( .unwrap() .contains("WorkspaceConfigSchema") ); - config_inspect(&wip, "/workspace-config").await; + config_discover(&wip, "/workspace-config").await; config_call( &wip, "/workspace-config", diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 5dcd62d50..7ce8040f8 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -3,120 +3,19 @@ use std::time::{SystemTime, UNIX_EPOCH}; use axum::http::HeaderMap; use worker_runtime::auth::{ - RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation, - WorkerMutationSourceClaims, WorkerMutationSourceExpectation, - decode_runtime_request_source_claims, decode_worker_mutation_source_claims, - verify_runtime_request_source, verify_worker_mutation_source_proof, + WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, + WorkerMutationSourceExpectation, decode_worker_mutation_source_claims, + verify_worker_mutation_source_proof, }; use worker_runtime::worker_source::InProcessWorkerMutationProof; use crate::server::{ServerConfig, WorkspaceApi}; -use crate::store::ControlPlaneStore; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VerifiedRuntimeRequestSource { - pub runtime_id: String, - pub worker_id: Option, -} - -pub async fn verify_runtime_request_source_proof( - api: &WorkspaceApi, - proof: &str, - workspace_id: &str, - permission: &str, - method: &str, - path: &str, - body_digest: &str, -) -> Result { - verify_runtime_request_source_proof_with_store( - api.store.as_ref(), - &api.config, - proof, - workspace_id, - permission, - method, - path, - body_digest, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -pub async fn verify_runtime_request_source_proof_with_store( - store: &dyn ControlPlaneStore, - config: &ServerConfig, - proof: &str, - workspace_id: &str, - permission: &str, - method: &str, - path: &str, - body_digest: &str, -) -> Result { - let unverified = decode_runtime_request_source_claims(proof) - .map_err(|_| WorkerMutationSourceProofError::Invalid)?; - let audience = remote_audience(config, workspace_id)?; - let trusted = store - .get_workspace_runtime_binding(workspace_id, &unverified.iss) - .await - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - .filter(|record| record.revoked_at.is_none()) - .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; - let public_key = trusted.public_key.as_str(); - let expected = RuntimeRequestSourceExpectation { - identity_id: &unverified.iss, - audience: audience.as_ref(), - workspace_id, - worker_id: unverified.worker_id.as_deref(), - permission, - method, - path, - body_digest, - now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), - }; - let claims = - verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?; - let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX); - let expires_at = u64::try_from(claims.exp).unwrap_or(0); - let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0) - .ok_or(WorkerMutationSourceProofError::Expired)? - .to_rfc3339(); - if !store - .consume_worker_mutation_source_jti( - workspace_id, - &claims.iss, - &claims.jti, - expires_at, - now_seconds, - &consumed_at, - ) - .await - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? - { - return Err(WorkerMutationSourceProofError::Replay); - } - if let Some(worker_id) = claims.worker_id.as_deref() { - let worker = worker_runtime::identity::RuntimeWorkerRef { - runtime_id: claims.iss.clone(), - worker_id: worker_id.to_owned(), - }; - let member = store - .get_worker_registry(workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - let reserved = store - .has_active_worker_create_reservation(workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - if member.is_none() && !reserved { - return Err(WorkerMutationSourceProofError::WorkerCatalogMembership); - } - store - .require_current_worker_singleton_owner(workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - } - Ok(VerifiedRuntimeRequestSource { - runtime_id: claims.iss, - worker_id: claims.worker_id, - }) -} +mod runtime_request; +pub use runtime_request::{ + RuntimeRequestProofError, VerifiedRuntimeRequestSource, diagnostic_id_hash, + verify_runtime_request_source_proof, verify_runtime_request_source_proof_with_store, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PresentedWorkerMutationSourceProof<'a> { diff --git a/crates/workspace-server/src/worker_source/runtime_request.rs b/crates/workspace-server/src/worker_source/runtime_request.rs new file mode 100644 index 000000000..00c00bd49 --- /dev/null +++ b/crates/workspace-server/src/worker_source/runtime_request.rs @@ -0,0 +1,248 @@ +use worker_runtime::auth::{ + RuntimeAuthError, RuntimeRequestClaim, RuntimeRequestSourceClaims, + RuntimeRequestSourceExpectation, decode_runtime_request_source_claims, + verify_runtime_request_source, +}; + +use super::{remote_audience, unix_now_seconds}; +use crate::server::{ServerConfig, WorkspaceApi}; +use crate::store::ControlPlaneStore; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedRuntimeRequestSource { + pub runtime_id: String, + pub worker_id: Option, +} + +/// Safe diagnostics only: never stores proof, raw claims, keys or store errors. +/// Verification is progress through proof checks, NOT an authenticated principal. +#[derive(Clone, Debug, thiserror::Error)] +#[error("invalid runtime request proof")] +pub struct RuntimeRequestProofError { + pub(crate) reason: &'static str, + pub(crate) verification: &'static str, + pub(crate) runtime_id_hash: Option, + pub(crate) worker_id_hash: Option, + pub(crate) token_id_hash: Option, + pub(crate) iat: Option, + pub(crate) exp: Option, + pub(crate) now: Option, + pub(crate) time_delta_seconds: Option, +} + +impl RuntimeRequestProofError { + fn undecoded() -> Self { + Self { + reason: "invalid", + verification: "unverified", + runtime_id_hash: None, + worker_id_hash: None, + token_id_hash: None, + iat: None, + exp: None, + now: None, + time_delta_seconds: None, + } + } + + fn decoded(claims: &RuntimeRequestSourceClaims) -> Self { + Self { + runtime_id_hash: Some(diagnostic_id_hash(&claims.iss)), + worker_id_hash: claims.worker_id.as_deref().map(diagnostic_id_hash), + token_id_hash: Some(diagnostic_id_hash(&claims.jti)), + ..Self::undecoded() + } + } + + fn reason(mut self, reason: &'static str) -> Self { + self.reason = reason; + self + } + + fn auth_error(mut self, error: RuntimeAuthError) -> Self { + self.reason = match error { + RuntimeAuthError::InvalidTokenFormat => "invalid_format", + RuntimeAuthError::InvalidBase64(_) => "invalid_encoding", + RuntimeAuthError::MalformedClaims(_) => "malformed_claims", + RuntimeAuthError::InvalidPublicKeyFormat => "invalid_runtime_public_key", + RuntimeAuthError::InvalidSignature => "invalid_signature", + RuntimeAuthError::ClaimMismatch(claim) => { + self.verification = "signature_verified"; + match claim { + RuntimeRequestClaim::Issuer => "issuer_mismatch", + RuntimeRequestClaim::Audience => "audience_mismatch", + RuntimeRequestClaim::Workspace => "workspace_mismatch", + RuntimeRequestClaim::Worker => "worker_mismatch", + RuntimeRequestClaim::Permission => "permission_mismatch", + RuntimeRequestClaim::Method => "method_mismatch", + RuntimeRequestClaim::Path => "path_mismatch", + RuntimeRequestClaim::BodyDigest => "body_digest_mismatch", + } + } + RuntimeAuthError::RequestIssuedInFuture { iat, exp, now } => { + self.time(iat, exp, now, iat.abs_diff(now)); + "issued_in_future" + } + RuntimeAuthError::RequestExpired { iat, exp, now } => { + self.time(iat, exp, now, now.abs_diff(exp)); + "expired" + } + _ => "invalid", + }; + self + } + + fn time(&mut self, iat: i64, exp: i64, now: i64, delta: u64) { + self.verification = "signature_verified"; + self.iat = Some(iat); + self.exp = Some(exp); + self.now = Some(now); + self.time_delta_seconds = Some(delta); + } +} + +/// Domain-separated fixed-length fingerprints, not raw request-derived IDs. +/// Operators can correlate a known ID by applying this same function. +pub fn diagnostic_id_hash(id: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + hash.update(b"yoi-runtime-proof-diagnostic-id-v1\0"); + hash.update(id.as_bytes()); + hash.finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[allow(clippy::too_many_arguments)] +pub async fn verify_runtime_request_source_proof( + api: &WorkspaceApi, + proof: &str, + workspace_id: &str, + permission: &str, + method: &str, + path: &str, + body_digest: &str, +) -> Result { + verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + &api.config, + proof, + workspace_id, + permission, + method, + path, + body_digest, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn verify_runtime_request_source_proof_with_store( + store: &dyn ControlPlaneStore, + config: &ServerConfig, + proof: &str, + workspace_id: &str, + permission: &str, + method: &str, + path: &str, + body_digest: &str, +) -> Result { + let unverified = decode_runtime_request_source_claims(proof) + .map_err(|error| RuntimeRequestProofError::undecoded().auth_error(error))?; + let diagnostic = RuntimeRequestProofError::decoded(&unverified); + let audience = remote_audience(config, workspace_id) + .map_err(|_| diagnostic.clone().reason("backend_audience_unavailable"))?; + let trusted = store + .get_workspace_runtime_binding(workspace_id, &unverified.iss) + .await + .map_err(|_| { + diagnostic + .clone() + .reason("runtime_trust_authority_unavailable") + })? + .filter(|record| record.revoked_at.is_none()) + .ok_or_else(|| { + diagnostic + .clone() + .reason("runtime_trust_missing_or_revoked") + })?; + let expected = RuntimeRequestSourceExpectation { + identity_id: &unverified.iss, + audience, + workspace_id, + worker_id: unverified.worker_id.as_deref(), + permission, + method, + path, + body_digest, + now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), + }; + let claims = + verify_runtime_request_source(proof, &trusted.public_key, &expected).map_err(|error| { + // Proof encoding was already decoded successfully. A base64 error + // at this stage is in the configured public key, not the proof. + let error = match error { + RuntimeAuthError::InvalidBase64(_) => RuntimeAuthError::InvalidPublicKeyFormat, + other => other, + }; + diagnostic.clone().auth_error(error) + })?; + let diagnostic = RuntimeRequestProofError { + verification: "request_verified", + ..diagnostic + }; + let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX); + let expires_at = u64::try_from(claims.exp).unwrap_or(0); + let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0) + .ok_or_else(|| diagnostic.clone().reason("verification_time_out_of_range"))? + .to_rfc3339(); + if !store + .consume_worker_mutation_source_jti( + workspace_id, + &claims.iss, + &claims.jti, + expires_at, + now_seconds, + &consumed_at, + ) + .await + .map_err(|_| diagnostic.clone().reason("replay_authority_unavailable"))? + { + return Err(diagnostic.reason("replay")); + } + if let Some(worker_id) = claims.worker_id.as_deref() { + let worker = worker_runtime::identity::RuntimeWorkerRef { + runtime_id: claims.iss.clone(), + worker_id: worker_id.to_owned(), + }; + let member = store + .get_worker_registry(workspace_id, &worker) + .map_err(|_| { + diagnostic + .clone() + .reason("worker_catalog_authority_unavailable") + })?; + let reserved = store + .has_active_worker_create_reservation(workspace_id, &worker) + .map_err(|_| { + diagnostic + .clone() + .reason("worker_reservation_authority_unavailable") + })?; + if member.is_none() && !reserved { + return Err(diagnostic.reason("worker_catalog_membership")); + } + store + .require_current_worker_singleton_owner(workspace_id, &worker) + .map_err(|_| { + diagnostic + .clone() + .reason("worker_singleton_authority_denied") + })?; + } + Ok(VerifiedRuntimeRequestSource { + runtime_id: claims.iss, + worker_id: claims.worker_id, + }) +} diff --git a/docs/development/runtime-request-proof-diagnostics.md b/docs/development/runtime-request-proof-diagnostics.md new file mode 100644 index 000000000..c6ffb8de1 --- /dev/null +++ b/docs/development/runtime-request-proof-diagnostics.md @@ -0,0 +1,83 @@ +# Runtime request proof rejection diagnostics + +`runtime_request_proof_rejected` (`yoi::auth`, WARN) is an internal operational +record. HTTP responses are unchanged: 401 with no proof details (repository +routes retain their existing generic API error envelope). It does **not** establish +why the reported same-machine intermittent 401 occurred. No reproduction or +causal measurement of that incident has been obtained. + +## Verification and correlation + +- `request_id`: Server-generated UUID identifying this rejection event. It is not + a caller-supplied ID or a response header, and is not a cross-service trace ID. +- `claimed_runtime_id_hash`, `claimed_worker_id_hash`, `claimed_token_id_hash`: + lowercase 64-character hex SHA-256 of + `b"yoi-runtime-proof-diagnostic-id-v1\0" + id.as_bytes()`. + `worker_source::diagnostic_id_hash` exposes this correlation function. Hash a + known Runtime/Worker ID to find its failures; token ID hashes correlate replay + attempts without logging the proof or JTI itself. These are fingerprints, not + encryption: low-entropy IDs can be guessed. Do not place secrets in IDs. +- All claims-derived fields are prefixed `claimed_`, even after signature checks. + `proof_verification` describes check progress, **not an authorized principal**: + - `unverified`: decoding may have succeeded, but signature verification has not. + - `signature_verified`: signature passed; request binding or time failed. + - `request_verified`: signature, request bindings and time passed, but replay, + membership or authority failed. +- When decoding fails, there are no claims fingerprints. Do not infer a Runtime + from a malformed proof. No raw claims, proof, key, header, body or raw error is + logged. The body digest and claim mismatch values are also deliberately absent. +- Method, query-free path, and requested workspace are request metadata, not + authenticated claims. They are bounded to 32, 512 and 128 UTF-8 bytes, with + control characters removed. Truncated paths are not unique identifiers. Query + names and values are never included. Paths are operational metadata, not a + place to put secrets. + +## Reasons and their limits + +Decoding distinguishes `invalid_format`, `invalid_encoding`, and +`malformed_claims`; no parser error text is included. Signature checks distinguish +`invalid_runtime_public_key` from `invalid_signature` (which cannot tell whether +an attacker, wrong key, corruption, or configuration mismatch caused the failure). + +Request binding reports the first mismatch, in the existing check order: +`issuer_mismatch`, `audience_mismatch`, `workspace_mismatch`, `worker_mismatch`, +`permission_mismatch`, `method_mismatch`, `path_mismatch`, `body_digest_mismatch`. +The server selects the trusted key by the unverified issuer and expects that +same issuer/worker; issuer and worker mismatches can be diagnosed by the lower +verifier but are not independently detectable by the current server caller. +These diagnostics do not add new identity checks or change authorization. + +`backend_audience_unavailable` denotes missing Backend public URL configuration. +`runtime_trust_missing_or_revoked` deliberately covers both absence and revocation, +as the existing store lookup/filter does not preserve that distinction. +`replay` means JTI consumption returned false. `worker_catalog_membership` means +neither registry membership nor an active create reservation was found. + +Store failures identify their check stage without exposing raw errors: +`runtime_trust_authority_unavailable`, `replay_authority_unavailable`, +`worker_catalog_authority_unavailable`, `worker_reservation_authority_unavailable`, +`worker_singleton_authority_denied`. The last can represent either an owner +conflict or an authority failure: the store currently returns a shared error, +so the diagnostic does not guess which. `verification_time_out_of_range` means +that conversion to the JTI consumption timestamp failed; it is not proof expiry. +`invalid` is a fallback for lower verifier errors not classified for this path. + +## Time contract and T-728 coordination + +This change preserves strict integer-second checks, inclusive bounds, and +verification order: signature, request binding, then `iat > now`, then `exp < now`. +No skew tolerance is added. If both time predicates fail, future issuance wins. + +- `issued_in_future`: `time_delta_seconds = iat - now` (positive). +- `expired`: `time_delta_seconds = now - exp` (positive). +- Only these reasons include `issued_at_unix`, `expires_at_unix`, + `verified_at_unix` and `time_tolerance_seconds` (currently zero). +- Deltas use `i64::abs_diff`, preserving the full positive `u64` range without + overflow. All times come from the actual signature-verified rejection and + the exact clock sample used for that decision, not a second log-time sample. + +T-728 owns any change to tolerance/validity policy. If it changes these checks, +the error time data, logged effective tolerance, and boundary tests must change +together. Do not label future issuance as expiry or independently recompute the +rejection using a different clock sample. This is instrumentation for the next +incident, not evidence that the original incident has been resolved. diff --git a/docs/report/t729-restored-base-test-compile-blocker.md b/docs/report/t729-restored-base-test-compile-blocker.md new file mode 100644 index 000000000..1bdb6bdf0 --- /dev/null +++ b/docs/report/t729-restored-base-test-compile-blocker.md @@ -0,0 +1,33 @@ +# T-729: restored base test-only compile blocker + +The assigned checkout starts at `f9d4c97f` (WIP 0.2 integration). On 2026-10-08, +`cargo test -p yoi-workspace-server --lib auth_logging_tests` could not compile +because `server_workspace_config_wip_tests.rs` still calls `WipRuntime::discover` +(lines 152 and 192), which no longer exists, and external `WipRuntime::call` +(lines 170 and 200), which is now crate-private. At the initial failure those +files were unchanged by the T-729 diagnostic work. + +A narrow test filter still builds all lib test modules, so it cannot isolate the +new authentication boundary tests from this unrelated test API mismatch. This +was escalated on T-729 instead of silently disabling those tests or widening the +Ticket to migrate unrelated configuration tests. Future API migration work should +check affected test-only consumers as well as the normal compilation closure. + +The additional no-default-features check also fails in unchanged Runtime modules: +`resource.rs`, `worker_backend.rs` and `worker_source.rs` import feature-gated +`workspace_request`, `reqwest` and `futures` unconditionally. The no-default lib +test build additionally calls feature-gated filesystem constructors. This is not +an auth diagnostic regression and was not repaired by broadening T-729. Default +Runtime crate tests pass (345 library tests, 17 tests in each of the two binaries); +root `cargo check` passes. After correcting the T-729 SHA formatting, the Server +lib test build reports only the four existing WIP API errors above. + +## Resolution of the Server prerequisite + +Ticket T-729 seq7 explicitly authorized a narrow test-only migration. Commit +`717ff255` replaces the helpers with public Tree, path-centered Inspect and +structured InterfaceReference Invoke. All existing Backend authority, attachment, +mutation, stale observation and unknown-outcome assertions are retained. All 12 +`workspace_config_integration` tests pass, and the authentication logging tests +now run (3 passed). No production API was exposed or legacy API revived. The +no-default-features issue remains separate and unresolved.