fix: integrate safe runtime proof diagnostics

This commit is contained in:
2026-10-09 03:00:40 +09:00
10 changed files with 1003 additions and 260 deletions
+253 -16
View File
@@ -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
})
));
}
}
+1
View File
@@ -56,6 +56,7 @@ zeroize.workspace = true
webauthn-rs = { workspace = true }
[dev-dependencies]
wip-protocol.workspace = true
agen.workspace = true
tempfile.workspace = true
tools.workspace = true
+37 -24
View File
@@ -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
@@ -55522,7 +55529,10 @@ mod tests {
.await;
assert!(matches!(
result,
Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust)
Err(crate::worker_source::RuntimeRequestProofError {
reason: "runtime_trust_missing_or_revoked",
..
})
));
}
@@ -55598,7 +55608,10 @@ mod tests {
.await;
assert!(matches!(
result,
Err(crate::worker_source::WorkerMutationSourceProofError::WorkerCatalogMembership)
Err(crate::worker_source::RuntimeRequestProofError {
reason: "worker_catalog_membership",
..
})
));
}
@@ -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<Value> = text
.lines()
.map(|line| serde_json::from_str::<Value>(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<Value> = text
.lines()
.map(|line| serde_json::from_str::<Value>(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"
);
}
}
}
@@ -139,26 +139,34 @@ impl worker::WorkspaceClient for ConfigRouterClient {
Ok(response)
}
}
fn config_interface(path: &str) -> String {
// Use the published contextual interface identity, not a model-managed validator.
let hex = path
.bytes()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("yoi.workspace-config/node/v1/@/{hex}")
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_discover(runtime: &worker::wip::WipRuntime, path: &str) {
let observed = runtime
.discover(path.into(), 0, true)
.await
.unwrap()
.content
.unwrap();
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.contains(&config_interface(path)),
"interface must come from live discovery: {observed}"
inspected["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|interface| &interface["reference"] == expected),
"interface must come from live path-centered Inspect: {inspected}"
);
runtime.inspect(config_interface(path), true).await.unwrap();
}
async fn config_call(
runtime: &worker::wip::WipRuntime,
@@ -167,9 +175,12 @@ async fn config_call(
args: Value,
) -> std::result::Result<agen::tool::ToolOutput, agen::tool::ToolError> {
runtime
.call(
.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(),
@@ -183,23 +194,16 @@ async fn catalog_call(
base: &str,
operation: &str,
) -> Value {
let hex = path
.bytes()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let interface = format!("{base}/@/{hex}");
let discovered = runtime
.discover(path.into(), 0, true)
.await
.unwrap()
.content
.unwrap();
assert!(discovered.contains(&interface), "{discovered}");
runtime.inspect(interface.clone(), true).await.unwrap();
// 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
.call(
.invoke(
path.into(),
interface,
wip_protocol::InterfaceReference {
scope: path.into(),
name: base.into(),
},
operation.into(),
json!({}),
agen::tool::ToolExecutionContext::direct(),
@@ -635,7 +639,7 @@ async fn assert_config_production_http_adapter(
assert_config_catalog(&wip, &attached).await;
config_discover(&wip, "/workspace-config/main.dcdl").await;
let main_interface = wip
.inspect(config_interface("/workspace-config/main.dcdl"), true)
.inspect("/workspace-config/main.dcdl".into(), true)
.await
.unwrap()
.content
+8 -109
View File
@@ -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<String>,
}
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<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
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<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
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> {
@@ -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<String>,
}
/// 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<String>,
pub(crate) worker_id_hash: Option<String>,
pub(crate) token_id_hash: Option<String>,
pub(crate) iat: Option<i64>,
pub(crate) exp: Option<i64>,
pub(crate) now: Option<i64>,
pub(crate) time_delta_seconds: Option<u64>,
}
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<VerifiedRuntimeRequestSource, RuntimeRequestProofError> {
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<VerifiedRuntimeRequestSource, RuntimeRequestProofError> {
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,
})
}