fix: retain safe runtime proof rejection diagnostics

T-729: distinguish issued_in_future from expired with exact checked times and overflow-safe deltas; retain request-binding mismatch reasons and proof verification progress. Correlate claimed Runtime/Worker/JTI via fixed-size fingerprints and server-generated rejection IDs, bound request metadata, omit query/raw claims/error/body/header/key material. Preserve allow/deny ordering and generic 401 responses; document diagnostic limits and T-728 policy ownership. Reported same-machine incident remains unconfirmed.

Validation: worker-runtime auth filter 3 passed; worker-runtime crate 345 lib + 17 per binary passed; Server auth logging 3 passed (17 rejection cases on both routers plus bounded metadata and generic repository401); workspace_config_integration 12 passed; Server crate 692 lib + 11 bin passed; root cargo check; cargo fmt --all -- --check; rustfmt included config tests --check; git diff --check HEAD. Existing no-default Runtime feature-gate breakage remains documented and out of scope.
This commit is contained in:
2026-10-09 02:53:23 +09:00
parent 717ff2555f
commit 7bb4096373
7 changed files with 963 additions and 226 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
})
));
}
}