From ebac81adb2feb8dc7e124630c6da5641039e53a3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 4 Aug 2026 23:41:54 +0700 Subject: [PATCH 01/67] refactor(evidence): extract portable response verifier crate Move the Evidence response wire types, the payload contract, the SD-JWT VC mapping, and the strict relying-party verifier into a new `registry-evidence-verifier` workspace crate, and depend on it from `registry-evidence` so every existing path still resolves unchanged. A consumer that only verifies a stored response no longer has to build the Unix-only axum, tokio, and Rhai runtime crate. Security review notes: - Pure code motion plus re-exports. No verification, signing, evaluation, or audit logic changed, and no wire format, schema, media type, or protected header value changed. `products/evidence/scripts/check-contracts.sh` reproduces the generated contracts bit for bit, so the published Evidence payload schema is byte-identical. - Moved: `verifier.rs` and `sdjwt_vc.rs` in full with their tests, the response-side wire types out of `model.rs`, `AssuranceProfile`, `evidence_schema` with Evidence payload validation out of `contracts.rs`, and the response wire-format constants out of `lib.rs`. - Widened from `pub(crate)` to `pub`: the `EvidenceVerificationPolicyDocument` fields and the expected-subject, expected-output, and expected-form document types, which the runtime's local verification command already constructs; `ContractValidationError`; and `safe_json_integer`, so the request-side and response-side numeric bounds remain one rule. The verifier's own limits and internal helpers stay private, and Evidence payload validation stays `pub(crate)` in `registry-evidence::contracts`. - The new crate carries no tokio, axum, Rhai, reqwest, or Unix-only dependency and no `cfg(not(unix))` guard, so it can be linked from a client library. - The new crate's own tests sign their inputs with a test-only fixture issuer, because a development dependency back onto the runtime links a second instance of the wire types. The runtime signer is still verified against this verifier by the runtime's own suite. - The security and acceptance traceability indexes now name the verifier crate for the tests that moved, and the traceability checker accepts both Evidence source trees. Every mapped negative and every acceptance row still resolves to an executable test. Signed-off-by: Jeremi Joslin --- Cargo.lock | 20 ++ Cargo.toml | 2 + crates/registry-evidence-verifier/Cargo.toml | 31 ++ crates/registry-evidence-verifier/README.md | 68 ++++ .../src/contracts.rs | 139 ++++++++ .../src/fixtures.rs | 129 ++++++++ crates/registry-evidence-verifier/src/lib.rs | 55 ++++ .../registry-evidence-verifier/src/model.rs | 297 ++++++++++++++++++ .../src/sdjwt_vc.rs | 8 +- .../src/verifier.rs | 66 ++-- crates/registry-evidence/Cargo.toml | 1 + crates/registry-evidence/src/config.rs | 29 +- crates/registry-evidence/src/contracts.rs | 132 +------- crates/registry-evidence/src/lib.rs | 22 +- crates/registry-evidence/src/model.rs | 290 +---------------- .../tests/security_contract_traceability.rs | 12 +- .../acceptance-test-traceability.yaml | 36 +-- .../contracts/security-test-traceability.yaml | 44 +-- .../scripts/check-source-neutrality.sh | 2 + 19 files changed, 867 insertions(+), 516 deletions(-) create mode 100644 crates/registry-evidence-verifier/Cargo.toml create mode 100644 crates/registry-evidence-verifier/README.md create mode 100644 crates/registry-evidence-verifier/src/contracts.rs create mode 100644 crates/registry-evidence-verifier/src/fixtures.rs create mode 100644 crates/registry-evidence-verifier/src/lib.rs create mode 100644 crates/registry-evidence-verifier/src/model.rs rename crates/{registry-evidence => registry-evidence-verifier}/src/sdjwt_vc.rs (99%) rename crates/{registry-evidence => registry-evidence-verifier}/src/verifier.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index c9d0ae218..ad6b2c59b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5427,6 +5427,7 @@ dependencies = [ "jsonwebtoken", "rand_core 0.6.4", "rcgen", + "registry-evidence-verifier", "registry-platform-audit", "registry-platform-crypto", "registry-platform-httpsec", @@ -5457,6 +5458,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "registry-evidence-verifier" +version = "0.17.0" +dependencies = [ + "base64", + "chrono", + "jsonschema 0.18.3", + "registry-platform-crypto", + "registry-platform-sdjwt", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_norway", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "utoipa", +] + [[package]] name = "registry-evidencectl" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index 56f9e58e2..28f18ca7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/registry-config-report", "crates/registry-evidence", + "crates/registry-evidence-verifier", "crates/registry-evidencectl", "crates/registry-platform-audit", "crates/registry-platform-authcommon", @@ -42,6 +43,7 @@ unsafe_code = "forbid" [workspace.dependencies] registry-config-report = { path = "crates/registry-config-report", version = "0.17.0" } registry-evidence = { path = "crates/registry-evidence", version = "0.17.0" } +registry-evidence-verifier = { path = "crates/registry-evidence-verifier", version = "0.17.0" } registry-language-server = { path = "crates/registry-language-server", version = "0.17.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.17.0" } registry-mint = { path = "crates/registry-mint", version = "0.17.0" } diff --git a/crates/registry-evidence-verifier/Cargo.toml b/crates/registry-evidence-verifier/Cargo.toml new file mode 100644 index 000000000..70f7da692 --- /dev/null +++ b/crates/registry-evidence-verifier/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "registry-evidence-verifier" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Portable verification core for signed Evidence responses." +readme = "README.md" +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +base64.workspace = true +chrono.workspace = true +# The default features of this dependency pull an HTTP client and a command +# line parser, neither of which a portable verifier may carry. +jsonschema = { version = "0.18", default-features = false, features = ["draft202012"] } +registry-platform-crypto.workspace = true +registry-platform-sdjwt.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +utoipa.workspace = true + +[dev-dependencies] +serde_norway.workspace = true +tokio.workspace = true diff --git a/crates/registry-evidence-verifier/README.md b/crates/registry-evidence-verifier/README.md new file mode 100644 index 000000000..4504352a8 --- /dev/null +++ b/crates/registry-evidence-verifier/README.md @@ -0,0 +1,68 @@ +# registry-evidence-verifier + +Portable verification core for signed Evidence Version 1 responses. + +## What It Provides + +- `verifier::verify_flattened_jws` and `verifier::verify_sd_jwt_vc` for strict, + fail-closed acceptance of the two signed response formats against a + relying-party policy. +- `verifier::EvidenceVerificationPolicy` and its declarative + `EvidenceVerificationPolicyDocument` form, including expected subjects, + expected output value forms, accepted assurance profile, request nonce echo, + accepted assertion lifetime, and clock skew. +- `model` wire types: the closed `Evidence` payload, its public value forms, + the flattened JWS response, the unsigned envelope, and the JWKS document. +- `contracts::evidence_schema` and `contracts::evidence_contract_accepts`: the + single source of the published Evidence payload schema and of the payload + validation every verification performs. +- `sdjwt_vc` mapping between an Evidence payload and its SD-JWT VC issuance + input and disclosed claim set. + +## Typical Use + +```rust +use registry_evidence_verifier::{ + model::{Evidence, JwksDocument}, + verifier::{verify_flattened_jws, EvidenceVerificationPolicy, VerificationError}, +}; + +/// `serialized_jws` is the stored response body, `trusted_jwks` is the key set +/// the relying party pinned out of band, and `policy` carries the complete +/// independent expectation. The call returns the accepted payload or refuses. +fn accept( + serialized_jws: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + verify_flattened_jws(serialized_jws, trusted_jwks, policy) +} +``` + +`verify_flattened_jws_report` and `verify_sd_jwt_vc_report` are the same checks +with cryptographic authenticity reported separately from current validity. + +## Security Notes + +- Verification is fail-closed: an unexpected protected header member, a + disclosure that is not covered by the signed digests, a payload that the + Version 1 schema rejects, or an expectation the policy states but the payload + does not satisfy all return an error rather than a partial result. +- Every accepted input is bounded before it is decoded, so an oversized + response cannot be turned into unbounded work or allocation. +- Trusted keys come only from the caller-supplied JWKS document. This crate + never fetches key material and never accepts a key named by the response. +- The wire types redact their `Debug` output, so a verified payload cannot leak + disclosed material into a log line, a panic message, or a snapshot. +- This crate verifies one stateless assertion. It holds no revocation state, no + replay storage, and no authorization policy. + +## Testing + +```sh +cargo test -p registry-evidence-verifier +``` + +## License + +Apache-2.0. diff --git a/crates/registry-evidence-verifier/src/contracts.rs b/crates/registry-evidence-verifier/src/contracts.rs new file mode 100644 index 000000000..96a1c1a7a --- /dev/null +++ b/crates/registry-evidence-verifier/src/contracts.rs @@ -0,0 +1,139 @@ +//! The Evidence payload contract for Version 1. +//! +//! The schema literal here is the single source of the generated +//! `evidence-v1.schema.json` release artifact and of the payload validation +//! every verifier performs, so a response cannot be accepted against a +//! different shape than the one published. + +use std::sync::OnceLock; + +use jsonschema::{Draft, JSONSchema}; +use serde_json::{json, Value}; +use thiserror::Error; + +pub const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; +pub const EVIDENCE_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json"; +pub const REQUEST_NONCE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; + +static EVIDENCE_VALIDATOR: OnceLock> = OnceLock::new(); + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("built-in public contract schema failed to initialize")] +pub struct ContractValidationError; + +/// Validate a verified JWS payload against the exact generated Version 1 schema. +pub fn evidence_contract_accepts(value: &Value) -> Result { + match EVIDENCE_VALIDATOR.get_or_init(|| { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&evidence_schema()) + .map_err(|_| ContractValidationError) + }) { + Ok(validator) => Ok(validator.is_valid(value)), + Err(error) => Err(*error), + } +} + +pub fn evidence_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": EVIDENCE_SCHEMA_ID, + "title": "Evidence assertion payload Version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "assuranceProfile", "requestNonce", "id", "type", "supportsRequirement", "isConformantTo", + "issuedBy", "providedBy", "issuedAt", "observedAt", "validUntil", + "purpose", "audience", "configurationRevision", "subjects", "supportedValues" + ], + "properties": { + "schema": {"const": "registry.assertion-evidence/v1"}, + "assuranceProfile": {"enum": ["local", "production", "evidence-grade"]}, + "requestNonce": {"type": "string", "pattern": REQUEST_NONCE_PATTERN}, + "id": {"type": "string", "format": "uri", "maxLength": 512}, + "type": {"const": "Evidence"}, + "supportsRequirement": {"type": "string", "format": "uri", "maxLength": 512}, + "isConformantTo": {"type": "string", "format": "uri", "maxLength": 512}, + "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "issuedAt": {"type": "string", "format": "date-time"}, + "observedAt": {"type": "string", "format": "date-time"}, + "validUntil": {"type": "string", "format": "date-time"}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "audience": {"type": "string", "format": "uri", "maxLength": 512}, + "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, + "items": {"$ref": "#/$defs/subject-binding"} + }, + "supportedValues": { + "type": "array", "minItems": 1, "maxItems": 16, + "items": {"$ref": "#/$defs/supported-value"} + } + }, + "$defs": { + "subject-binding": { + "type": "object", "additionalProperties": false, + "required": ["role", "binding"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "binding": {"type": "string", "pattern": "^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} + } + }, + "supported-value": { + "type": "object", "additionalProperties": false, + "required": ["providesValueFor", "value"], + "properties": { + "providesValueFor": {"type": "string", "format": "uri", "maxLength": 512}, + "value": {"$ref": "#/$defs/value"} + } + }, + "value": { + "anyOf": [ + {"type": "boolean"}, + {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + {"type": "string", "minLength": 1, "maxLength": 1024}, + {"$ref": "#/$defs/bucket"}, + {"$ref": "#/$defs/entity-reference"}, + {"$ref": "#/$defs/structured"}, + { + "type": "array", "minItems": 1, "maxItems": 64, + "items": {"anyOf": [ + {"type": "string", "minLength": 1, "maxLength": 1024}, + {"$ref": "#/$defs/entity-reference"} + ]} + } + ] + }, + "bucket": { + "type": "object", "additionalProperties": false, + "required": ["form", "scheme", "bucket"], + "properties": { + "form": {"enum": ["date-bucket", "time-bucket"]}, + "scheme": {"type": "string", "format": "uri", "maxLength": 512}, + "bucket": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"} + } + }, + "entity-reference": { + "type": "object", "additionalProperties": false, + "required": ["form", "reference"], + "properties": { + "form": {"const": "audience-scoped-entity-reference"}, + "reference": {"type": "string", "pattern": "^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} + } + }, + "structured": { + "type": "object", "additionalProperties": false, + "required": ["form", "schema", "fields"], + "properties": { + "form": {"const": "reviewed-structured-value"}, + "schema": {"type": "string", "format": "uri", "maxLength": 512}, + "fields": {"type": "object", "minProperties": 1, "maxProperties": 16} + } + } + }, + "$comment": "The selected concept declaration further closes value form, schema, codelist, precision, cardinality, structured fields, and uniqueness. Selector profiles and selector values never appear in Evidence." + }) +} diff --git a/crates/registry-evidence-verifier/src/fixtures.rs b/crates/registry-evidence-verifier/src/fixtures.rs new file mode 100644 index 000000000..fb8784f02 --- /dev/null +++ b/crates/registry-evidence-verifier/src/fixtures.rs @@ -0,0 +1,129 @@ +//! Test-only issuer fixtures for this crate's own verification tests. +//! +//! The Evidence runtime owns signing and depends on this crate, so a test here +//! cannot reach the runtime signer: a development dependency back onto the +//! runtime would link a second instance of this crate and its wire types would +//! not unify. These fixtures produce authentic signed inputs from the same +//! protected header, key set, and SD-JWT VC issuance rules instead. The runtime +//! signer is verified against this crate by the runtime's own suite. + +use std::{collections::BTreeSet, sync::Arc}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{PublicJwk, SigningAlgorithm, SigningProvider}; +use registry_platform_sdjwt::{SdJwtIssuanceInput, SdJwtIssuer}; +use serde::Serialize; + +use crate::{ + model::{FlattenedJws, JwksDocument}, + EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, +}; + +/// Deterministic canonical nonce for offline fixture evaluation. Real callers +/// generate a fresh random value for every request. +pub const OFFLINE_EVALUATION_REQUEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +#[derive(Debug)] +pub enum FixtureSigningError { + Algorithm, + ActiveKeyId, + Provider, + PublishedKey, + Serialization, + SdJwtVc, +} + +#[derive(Serialize)] +struct ProtectedHeader<'a> { + alg: &'static str, + kid: &'a str, + typ: &'static str, + cty: &'static str, +} + +pub struct EvidenceSigner { + provider: Arc, +} + +impl EvidenceSigner { + pub async fn initialize( + provider: Arc, + configured_active_key_id: &str, + ) -> Result { + if provider.algorithm() != SigningAlgorithm::EdDsa { + return Err(FixtureSigningError::Algorithm); + } + if provider.key_id() != configured_active_key_id { + return Err(FixtureSigningError::ActiveKeyId); + } + Ok(Self { provider }) + } + + pub fn public_jwk(&self) -> PublicJwk { + self.provider.public_jwk() + } + + pub async fn sign_json( + &self, + evidence: &T, + ) -> Result { + let payload = + serde_json::to_vec(evidence).map_err(|_| FixtureSigningError::Serialization)?; + let protected = serde_json::to_vec(&ProtectedHeader { + alg: "EdDSA", + kid: self.provider.key_id(), + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }) + .map_err(|_| FixtureSigningError::Serialization)?; + + let protected = URL_SAFE_NO_PAD.encode(protected); + let payload = URL_SAFE_NO_PAD.encode(payload); + let signing_input = [protected.as_bytes(), b".", payload.as_bytes()].concat(); + let signature = self + .provider + .sign(&signing_input) + .await + .map_err(|_| FixtureSigningError::Provider)?; + + Ok(FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + }) + } + + pub async fn sign_sd_jwt_vc( + &self, + input: SdJwtIssuanceInput, + ) -> Result { + SdJwtIssuer::from_signing_provider(Arc::clone(&self.provider)) + .issue(input) + .await + .map(|signed| signed.jwt) + .map_err(|_| FixtureSigningError::SdJwtVc) + } +} + +/// Publish an active key and its retired predecessors as the trusted key set. +pub fn jwks_document( + active: PublicJwk, + retired: impl IntoIterator, +) -> Result { + let mut seen = BTreeSet::new(); + let mut keys = Vec::new(); + for key in std::iter::once(active).chain(retired) { + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(FixtureSigningError::Algorithm); + } + let key_id = key + .kid + .as_deref() + .ok_or(FixtureSigningError::PublishedKey)?; + if !seen.insert(key_id.to_owned()) { + return Err(FixtureSigningError::PublishedKey); + } + keys.push(serde_json::to_value(key).map_err(|_| FixtureSigningError::Serialization)?); + } + Ok(JwksDocument { keys }) +} diff --git a/crates/registry-evidence-verifier/src/lib.rs b/crates/registry-evidence-verifier/src/lib.rs new file mode 100644 index 000000000..fe7b99276 --- /dev/null +++ b/crates/registry-evidence-verifier/src/lib.rs @@ -0,0 +1,55 @@ +//! Portable Evidence Version 1 response-verification core. +//! +//! This crate owns the response wire formats, the Evidence payload contract, +//! and the strict relying-party verifier. It carries no server, no source +//! access, no configuration loading, and no platform-specific requirement, so +//! any consumer that can parse JSON can verify a stored response with the same +//! rules the runtime applies. + +pub mod contracts; +pub mod model; +pub mod sdjwt_vc; +pub mod verifier; + +#[cfg(test)] +mod fixtures; + +use serde::{Deserialize, Serialize}; + +pub const EVIDENCE_SCHEMA_V1: &str = "registry.assertion-evidence/v1"; +pub const EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1: &str = "registry.unsigned-evidence-envelope/v1"; +pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; +pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; +pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; +/// Compact SD-JWT VC serialization of the same assertion. The profile adds a +/// response format only; it introduces no credential lifecycle. +pub const EVIDENCE_SD_JWT_VC_MEDIA_TYPE: &str = "application/dc+sd-jwt"; +pub const EVIDENCE_SD_JWT_VC_TYP: &str = "dc+sd-jwt"; +pub const EVIDENCE_UNSIGNED_MEDIA_TYPE: &str = + "application/vnd.registrystack.evidence-unsigned+json"; + +#[derive( + Debug, + Clone, + Copy, + Eq, + PartialEq, + Deserialize, + Serialize, + schemars::JsonSchema, + utoipa::ToSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum AssuranceProfile { + Local, + Production, + EvidenceGrade, +} + +impl AssuranceProfile { + /// Only the explicit local profile may be authored before fixture + /// coverage exists. Deployable profiles retain the complete fixture gate. + pub fn requires_fixtures(self) -> bool { + matches!(self, Self::Production | Self::EvidenceGrade) + } +} diff --git a/crates/registry-evidence-verifier/src/model.rs b/crates/registry-evidence-verifier/src/model.rs new file mode 100644 index 000000000..f23ac8071 --- /dev/null +++ b/crates/registry-evidence-verifier/src/model.rs @@ -0,0 +1,297 @@ +//! Response-side wire types: the Evidence payload, its public value forms, and +//! the three response serializations a relying party can receive. + +use std::{collections::BTreeMap, fmt}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use schemars::JsonSchema; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_json::{Number, Value}; +use utoipa::ToSchema; + +use crate::AssuranceProfile; + +/// Caller-supplied Ed25519 holder public key. `deny_unknown_fields` is the +/// primary defence against private key members: a body carrying `d` or any +/// other unexpected member fails to parse. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HolderPublicKey { + pub kty: String, + pub crv: String, + pub x: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kid: Option, +} + +/// Exact byte length of a raw Ed25519 public key. +const HOLDER_KEY_DECODED_LENGTH: usize = 32; +const MAX_HOLDER_KEY_ID_BYTES: usize = 256; + +impl HolderPublicKey { + /// Accept only a public OKP Ed25519 JWK whose coordinate is the canonical + /// unpadded base64url encoding of exactly 32 bytes. + pub fn is_acceptable(&self) -> bool { + if self.kty != "OKP" || self.crv != "Ed25519" { + return false; + } + if self.alg.as_deref().is_some_and(|alg| alg != "EdDSA") { + return false; + } + if self + .kid + .as_deref() + .is_some_and(|kid| kid.is_empty() || kid.len() > MAX_HOLDER_KEY_ID_BYTES) + { + return false; + } + URL_SAFE_NO_PAD + .decode(&self.x) + .is_ok_and(|decoded| decoded.len() == HOLDER_KEY_DECODED_LENGTH) + } +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Evidence { + pub schema: String, + pub assurance_profile: AssuranceProfile, + /// Exact echo of the caller's request nonce for request-response + /// correlation. The runtime does not store it or reject reuse. + pub request_nonce: String, + pub id: String, + #[serde(rename = "type")] + pub evidence_type_name: EvidenceObjectType, + pub supports_requirement: String, + pub is_conformant_to: String, + pub issued_by: String, + pub provided_by: String, + pub issued_at: String, + pub observed_at: String, + pub valid_until: String, + pub purpose: String, + pub audience: String, + pub configuration_revision: String, + pub subjects: Vec, + pub supported_values: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +pub enum EvidenceObjectType { + Evidence, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjectBinding { + pub role: String, + pub binding: String, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SupportedValue { + pub provides_value_for: String, + pub value: PublicValue, +} + +#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum PublicValue { + Boolean(bool), + Integer(i64), + String(String), + Bucket(BucketValue), + EntityReference(EntityReferenceValue), + Structured(StructuredValue), + List(Vec), +} + +impl<'de> Deserialize<'de> for PublicValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value { + Value::Bool(value) => Ok(Self::Boolean(value)), + Value::Number(value) => safe_json_integer(&value) + .map(Self::Integer) + .ok_or_else(|| de::Error::custom("public number is not a safe JSON integer")), + Value::String(value) => Ok(Self::String(value)), + Value::Array(values) => serde_json::from_value(Value::Array(values)) + .map(Self::List) + .map_err(de::Error::custom), + Value::Object(object) => { + let form = object + .get("form") + .and_then(Value::as_str) + .map(str::to_owned); + let value = Value::Object(object); + match form.as_deref() { + Some("date-bucket" | "time-bucket") => serde_json::from_value(value) + .map(Self::Bucket) + .map_err(de::Error::custom), + Some("audience-scoped-entity-reference") => serde_json::from_value(value) + .map(Self::EntityReference) + .map_err(de::Error::custom), + Some("reviewed-structured-value") => serde_json::from_value(value) + .map(Self::Structured) + .map_err(de::Error::custom), + _ => Err(de::Error::custom("public object has an unsupported form")), + } + } + Value::Null => Err(de::Error::custom("public value cannot be null")), + } + } +} + +/// Canonicalize a JSON number to the safe integer range shared by every +/// scalar the wire formats accept. +pub fn safe_json_integer(number: &Number) -> Option { + const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + + if let Some(value) = number.as_i64() { + return (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER) + .contains(&value) + .then_some(value); + } + if let Some(value) = number.as_u64() { + return (value <= MAX_SAFE_INTEGER as u64).then_some(value as i64); + } + let value = number.as_f64()?; + (value.is_finite() + && value.fract() == 0.0 + && value >= -(MAX_SAFE_INTEGER as f64) + && value <= MAX_SAFE_INTEGER as f64) + .then_some(value as i64) +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum ScalarOrEntityReference { + String(String), + EntityReference(EntityReferenceValue), +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct BucketValue { + pub form: BucketForm, + pub scheme: String, + pub bucket: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum BucketForm { + DateBucket, + TimeBucket, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EntityReferenceValue { + pub form: EntityReferenceForm, + pub reference: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum EntityReferenceForm { + AudienceScopedEntityReference, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct StructuredValue { + pub form: StructuredValueForm, + pub schema: String, + pub fields: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum StructuredValueForm { + ReviewedStructuredValue, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct FlattenedJws { + pub protected: String, + pub payload: String, + pub signature: String, +} + +/// Self-identifying unsigned response envelope. It deliberately does not +/// serialize as the signed Evidence payload by itself and carries no JWS +/// member, so the strict JWS verifier rejects it. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnsignedEvidenceEnvelope { + pub schema: String, + #[serde(rename = "type")] + pub envelope_type: UnsignedEnvelopeType, + pub integrity_protection: UnsignedIntegrityProtection, + pub warning: UnsignedEnvelopeWarning, + pub evidence: Evidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +pub enum UnsignedEnvelopeType { + UnsignedEvidenceEnvelope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum UnsignedIntegrityProtection { + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum UnsignedEnvelopeWarning { + NotCryptographicallyVerifiable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct JwksDocument { + pub keys: Vec, +} + +/// Replace the derived `Debug` of a wire type with a redacted placeholder so +/// disclosed material cannot reach a log line, a panic message, or a snapshot. +/// Callers must have `std::fmt` in scope. +#[macro_export] +macro_rules! redacted_debug { + ($($type_name:ty),+ $(,)?) => { + $( + impl fmt::Debug for $type_name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct(stringify!($type_name)) + .field("protected", &"") + .finish() + } + } + )+ + }; +} + +redacted_debug!( + HolderPublicKey, + Evidence, + SubjectBinding, + SupportedValue, + PublicValue, + ScalarOrEntityReference, + BucketValue, + EntityReferenceValue, + StructuredValue, + FlattenedJws, + UnsignedEvidenceEnvelope, +); diff --git a/crates/registry-evidence/src/sdjwt_vc.rs b/crates/registry-evidence-verifier/src/sdjwt_vc.rs similarity index 99% rename from crates/registry-evidence/src/sdjwt_vc.rs rename to crates/registry-evidence-verifier/src/sdjwt_vc.rs index 8f5f5fa55..106f82017 100644 --- a/crates/registry-evidence/src/sdjwt_vc.rs +++ b/crates/registry-evidence-verifier/src/sdjwt_vc.rs @@ -432,16 +432,14 @@ fn rfc3339_of(claims: &Map, name: &str) -> Result Evidence { Evidence { schema: EVIDENCE_SCHEMA_V1.to_string(), - assurance_profile: crate::config::AssuranceProfile::EvidenceGrade, + assurance_profile: crate::AssuranceProfile::EvidenceGrade, request_nonce: OFFLINE_EVALUATION_REQUEST_NONCE.to_string(), id: "urn:evidence:assertion:v1_2f0a".to_string(), evidence_type_name: EvidenceObjectType::Evidence, diff --git a/crates/registry-evidence/src/verifier.rs b/crates/registry-evidence-verifier/src/verifier.rs similarity index 98% rename from crates/registry-evidence/src/verifier.rs rename to crates/registry-evidence-verifier/src/verifier.rs index 619cd4435..d724ef8dd 100644 --- a/crates/registry-evidence/src/verifier.rs +++ b/crates/registry-evidence-verifier/src/verifier.rs @@ -15,11 +15,11 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::{ - config::AssuranceProfile, contracts::evidence_contract_accepts, model::{Evidence, FlattenedJws, JwksDocument}, sdjwt_vc::evidence_payload_from_claims, - EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, EVIDENCE_SD_JWT_VC_TYP, + AssuranceProfile, EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, + EVIDENCE_SD_JWT_VC_TYP, }; const MAX_JWS_BYTES: usize = 256 * 1024; @@ -70,35 +70,35 @@ pub struct EvidenceVerificationPolicy { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EvidenceVerificationPolicyDocument { - pub(crate) expected_assurance_profile: AssuranceProfile, - pub(crate) issued_by: String, - pub(crate) provided_by: String, - pub(crate) requirement: String, - pub(crate) evidence_type: String, - pub(crate) purpose: String, - pub(crate) audience: String, - pub(crate) configuration_revision: String, + pub expected_assurance_profile: AssuranceProfile, + pub issued_by: String, + pub provided_by: String, + pub requirement: String, + pub evidence_type: String, + pub purpose: String, + pub audience: String, + pub configuration_revision: String, /// The exact nonce from the independently retained original request. - pub(crate) request_nonce: String, - pub(crate) expected_subjects: Vec, - pub(crate) expected_outputs: Vec, - pub(crate) maximum_assertion_lifetime_seconds: u64, + pub request_nonce: String, + pub expected_subjects: Vec, + pub expected_outputs: Vec, + pub maximum_assertion_lifetime_seconds: u64, #[serde(default)] - pub(crate) clock_skew_seconds: u64, + pub clock_skew_seconds: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ExpectedSubjectDocument { - pub(crate) role: String, - pub(crate) binding: String, +pub struct ExpectedSubjectDocument { + pub role: String, + pub binding: String, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ExpectedOutputDocument { - pub(crate) concept: String, - pub(crate) form: ExpectedFormDocument, +pub struct ExpectedOutputDocument { + pub concept: String, + pub form: ExpectedFormDocument, } /// The closed expected value-form vocabulary as written in a policy document. @@ -107,14 +107,14 @@ pub(crate) struct ExpectedOutputDocument { /// form as a plain string and the list form as a mapping under `list`. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] -pub(crate) enum ExpectedFormDocument { +pub enum ExpectedFormDocument { Scalar(ExpectedScalarFormDocument), List(ExpectedListFormDocument), } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] -pub(crate) enum ExpectedScalarFormDocument { +pub enum ExpectedScalarFormDocument { Boolean, Integer, String, @@ -126,15 +126,15 @@ pub(crate) enum ExpectedScalarFormDocument { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -pub(crate) struct ExpectedListFormDocument { - pub(crate) list: ExpectedListDocument, +pub struct ExpectedListFormDocument { + pub list: ExpectedListDocument, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ExpectedListDocument { - pub(crate) minimum_items: usize, - pub(crate) maximum_items: usize, +pub struct ExpectedListDocument { + pub minimum_items: usize, + pub maximum_items: usize, } impl EvidenceVerificationPolicyDocument { @@ -913,12 +913,10 @@ mod tests { use serde_json::{json, Value}; use super::*; - use crate::{ - model::{ - EvidenceObjectType, PublicValue, StructuredValue, StructuredValueForm, SubjectBinding, - SupportedValue, - }, - signing::{jwks_document, EvidenceSigner}, + use crate::fixtures::{jwks_document, EvidenceSigner}; + use crate::model::{ + EvidenceObjectType, PublicValue, StructuredValue, StructuredValueForm, SubjectBinding, + SupportedValue, }; const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index cf748e311..cea54a01c 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -28,6 +28,7 @@ fs2.workspace = true http.workspace = true jsonschema.workspace = true jsonwebtoken.workspace = true +registry-evidence-verifier.workspace = true registry-platform-audit.workspace = true registry-platform-crypto.workspace = true registry-platform-httpsec.workspace = true diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index 7b1798da2..306ec30b2 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -357,31 +357,10 @@ pub struct EvidenceConfig { pub requirements: Vec, } -#[derive( - Debug, - Clone, - Copy, - Eq, - PartialEq, - Deserialize, - Serialize, - schemars::JsonSchema, - utoipa::ToSchema, -)] -#[serde(rename_all = "kebab-case")] -pub enum AssuranceProfile { - Local, - Production, - EvidenceGrade, -} - -impl AssuranceProfile { - /// Only the explicit local profile may be authored before fixture - /// coverage exists. Deployable profiles retain the complete fixture gate. - pub fn requires_fixtures(self) -> bool { - matches!(self, Self::Production | Self::EvidenceGrade) - } -} +/// The declared assurance boundary travels with every response, so the +/// portable `registry-evidence-verifier` crate owns it and configuration serves +/// it at the runtime's own path. +pub use registry_evidence_verifier::AssuranceProfile; pub type SourceSelectorSet = Vec<(String, String)>; diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index 8ac58518d..9fd57210c 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -1,7 +1,9 @@ //! Deterministic public-contract generation for Evidence Version 1. //! -//! The generated files are release artifacts. This module is their only -//! source and deliberately has no dependency on a deployment bundle. +//! The generated files are release artifacts. This module is their source, +//! together with the Evidence payload schema the portable +//! `registry-evidence-verifier` crate owns, and deliberately has no dependency +//! on a deployment bundle. use std::{ collections::BTreeMap, @@ -11,6 +13,10 @@ use std::{ }; use jsonschema::{Draft, JSONSchema}; +pub(crate) use registry_evidence_verifier::contracts::{ + evidence_schema, ContractValidationError, EVIDENCE_SCHEMA_ID, REQUEST_NONCE_PATTERN, + SCHEMA_DIALECT, +}; use schemars::JsonSchema; use serde_json::{json, Value}; use thiserror::Error; @@ -20,6 +26,11 @@ use crate::model::{ UnsignedEvidenceEnvelope, }; +/// Evidence payload validation belongs to verification, which the portable +/// crate owns. The runtime exercises it from its own tests. +#[cfg(test)] +pub(crate) use registry_evidence_verifier::contracts::evidence_contract_accepts; + pub const OPENAPI_FILE: &str = "registry-evidence.openapi.json"; pub const REQUEST_SCHEMA_FILE: &str = "evidence-request-v1.schema.json"; pub const EVIDENCE_SCHEMA_FILE: &str = "evidence-v1.schema.json"; @@ -29,10 +40,7 @@ pub const UNSIGNED_ENVELOPE_SCHEMA_FILE: &str = "evidence-unsigned-envelope-v1.s pub const PROBLEM_SCHEMA_FILE: &str = "problem-v1.schema.json"; pub const JWKS_SCHEMA_FILE: &str = "jwks-v1.schema.json"; -const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; const REQUEST_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/request-v1.json"; -const EVIDENCE_SCHEMA_ID: &str = - "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json"; const DEFINITIONS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/definitions-v1.json"; const JWS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/flattened-jws-v1.json"; @@ -40,7 +48,6 @@ const UNSIGNED_ENVELOPE_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/unsigned-envelope-v1.json"; const PROBLEM_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/problem-v1.json"; const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1.json"; -const REQUEST_NONCE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; /// Shape of the server-minted operation identifier, shared by the response /// header and the problem member so the two cannot describe different values. const OPERATION_PATTERN: &str = "^[0-9A-HJKMNP-TV-Z]{26}$"; @@ -76,14 +83,9 @@ const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ static SERVED_OPENAPI: OnceLock> = OnceLock::new(); static REQUEST_VALIDATOR: OnceLock> = OnceLock::new(); -static EVIDENCE_VALIDATOR: OnceLock> = OnceLock::new(); static DEFINITIONS_VALIDATOR: OnceLock> = OnceLock::new(); -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] -#[error("built-in public contract schema failed to initialize")] -pub(crate) struct ContractValidationError; - #[derive(Debug, Error)] pub enum ContractGenerationError { #[error("generated contract serialization failed")] @@ -181,12 +183,6 @@ pub(crate) fn request_contract_accepts(value: &Value) -> Result Result { - contract_validator(&EVIDENCE_VALIDATOR, evidence_schema) - .map(|validator| validator.is_valid(value)) -} - /// Validate an outbound discovery response against the exact generated /// Version 1 schema. pub(crate) fn definitions_contract_accepts(value: &Value) -> Result { @@ -467,108 +463,6 @@ fn definitions_schema() -> Value { }) } -fn evidence_schema() -> Value { - json!({ - "$schema": SCHEMA_DIALECT, - "$id": EVIDENCE_SCHEMA_ID, - "title": "Evidence assertion payload Version 1", - "type": "object", - "additionalProperties": false, - "required": [ - "schema", "assuranceProfile", "requestNonce", "id", "type", "supportsRequirement", "isConformantTo", - "issuedBy", "providedBy", "issuedAt", "observedAt", "validUntil", - "purpose", "audience", "configurationRevision", "subjects", "supportedValues" - ], - "properties": { - "schema": {"const": "registry.assertion-evidence/v1"}, - "assuranceProfile": {"enum": ["local", "production", "evidence-grade"]}, - "requestNonce": {"type": "string", "pattern": REQUEST_NONCE_PATTERN}, - "id": {"type": "string", "format": "uri", "maxLength": 512}, - "type": {"const": "Evidence"}, - "supportsRequirement": {"type": "string", "format": "uri", "maxLength": 512}, - "isConformantTo": {"type": "string", "format": "uri", "maxLength": 512}, - "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, - "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, - "issuedAt": {"type": "string", "format": "date-time"}, - "observedAt": {"type": "string", "format": "date-time"}, - "validUntil": {"type": "string", "format": "date-time"}, - "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, - "audience": {"type": "string", "format": "uri", "maxLength": 512}, - "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, - "subjects": { - "type": "array", "minItems": 1, "maxItems": 8, - "items": {"$ref": "#/$defs/subject-binding"} - }, - "supportedValues": { - "type": "array", "minItems": 1, "maxItems": 16, - "items": {"$ref": "#/$defs/supported-value"} - } - }, - "$defs": { - "subject-binding": { - "type": "object", "additionalProperties": false, - "required": ["role", "binding"], - "properties": { - "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, - "binding": {"type": "string", "pattern": "^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} - } - }, - "supported-value": { - "type": "object", "additionalProperties": false, - "required": ["providesValueFor", "value"], - "properties": { - "providesValueFor": {"type": "string", "format": "uri", "maxLength": 512}, - "value": {"$ref": "#/$defs/value"} - } - }, - "value": { - "anyOf": [ - {"type": "boolean"}, - {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, - {"type": "string", "minLength": 1, "maxLength": 1024}, - {"$ref": "#/$defs/bucket"}, - {"$ref": "#/$defs/entity-reference"}, - {"$ref": "#/$defs/structured"}, - { - "type": "array", "minItems": 1, "maxItems": 64, - "items": {"anyOf": [ - {"type": "string", "minLength": 1, "maxLength": 1024}, - {"$ref": "#/$defs/entity-reference"} - ]} - } - ] - }, - "bucket": { - "type": "object", "additionalProperties": false, - "required": ["form", "scheme", "bucket"], - "properties": { - "form": {"enum": ["date-bucket", "time-bucket"]}, - "scheme": {"type": "string", "format": "uri", "maxLength": 512}, - "bucket": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"} - } - }, - "entity-reference": { - "type": "object", "additionalProperties": false, - "required": ["form", "reference"], - "properties": { - "form": {"const": "audience-scoped-entity-reference"}, - "reference": {"type": "string", "pattern": "^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} - } - }, - "structured": { - "type": "object", "additionalProperties": false, - "required": ["form", "schema", "fields"], - "properties": { - "form": {"const": "reviewed-structured-value"}, - "schema": {"type": "string", "format": "uri", "maxLength": 512}, - "fields": {"type": "object", "minProperties": 1, "maxProperties": 16} - } - } - }, - "$comment": "The selected concept declaration further closes value form, schema, codelist, precision, cardinality, structured fields, and uniqueness. Selector profiles and selector values never appear in Evidence." - }) -} - fn jws_schema() -> Value { json!({ "$schema": SCHEMA_DIALECT, diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs index 3df152990..f39e316f8 100644 --- a/crates/registry-evidence/src/lib.rs +++ b/crates/registry-evidence/src/lib.rs @@ -17,27 +17,23 @@ pub mod problem; pub mod rate_limit; pub mod rhai_runtime; pub mod runtime; -pub mod sdjwt_vc; pub mod secrets; pub mod selector; pub mod server; pub mod signing; pub mod source; pub mod values; -pub mod verifier; + +/// The response formats, their payload contract, and the strict verifier are +/// owned by the portable `registry-evidence-verifier` crate and served here at +/// the runtime's own paths. +pub use registry_evidence_verifier::{ + sdjwt_vc, verifier, EVIDENCE_JWS_CTY, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_JWS_TYP, + EVIDENCE_SCHEMA_V1, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_TYP, + EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, EVIDENCE_UNSIGNED_MEDIA_TYPE, +}; #[cfg(test)] mod runtime_tests; -pub const EVIDENCE_SCHEMA_V1: &str = "registry.assertion-evidence/v1"; pub const EVIDENCE_DEFINITIONS_SCHEMA_V1: &str = "registry.evidence-definitions/v1"; -pub const EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1: &str = "registry.unsigned-evidence-envelope/v1"; -pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; -pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; -pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; -/// Compact SD-JWT VC serialization of the same assertion. The profile adds a -/// response format only; it introduces no credential lifecycle. -pub const EVIDENCE_SD_JWT_VC_MEDIA_TYPE: &str = "application/dc+sd-jwt"; -pub const EVIDENCE_SD_JWT_VC_TYP: &str = "dc+sd-jwt"; -pub const EVIDENCE_UNSIGNED_MEDIA_TYPE: &str = - "application/vnd.registrystack.evidence-unsigned+json"; diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index edb517324..09d78f4fb 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -1,13 +1,25 @@ use std::{collections::BTreeMap, fmt}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_evidence_verifier::{model::safe_json_integer, redacted_debug}; use schemars::JsonSchema; use serde::{de, Deserialize, Deserializer, Serialize}; -use serde_json::{Number, Value}; +use serde_json::Value; use utoipa::ToSchema; use crate::config::AssuranceProfile; +/// The response-side wire types are owned by the portable +/// `registry-evidence-verifier` crate and served here at the runtime's own +/// paths, beside the request-side types that only the runtime needs. +pub use registry_evidence_verifier::model::{ + BucketForm, BucketValue, EntityReferenceForm, EntityReferenceValue, Evidence, + EvidenceObjectType, FlattenedJws, HolderPublicKey, JwksDocument, PublicValue, + ScalarOrEntityReference, StructuredValue, StructuredValueForm, SubjectBinding, SupportedValue, + UnsignedEnvelopeType, UnsignedEnvelopeWarning, UnsignedEvidenceEnvelope, + UnsignedIntegrityProtection, +}; + /// Exact encoded length of the required caller-generated request nonce: the /// canonical unpadded base64url form of 32 random bytes. pub const REQUEST_NONCE_ENCODED_LENGTH: usize = 43; @@ -55,48 +67,6 @@ pub struct EvidenceRequest { pub holder_key: Option, } -/// Caller-supplied Ed25519 holder public key. `deny_unknown_fields` is the -/// primary defence against private key members: a body carrying `d` or any -/// other unexpected member fails to parse. -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct HolderPublicKey { - pub kty: String, - pub crv: String, - pub x: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub alg: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kid: Option, -} - -/// Exact byte length of a raw Ed25519 public key. -const HOLDER_KEY_DECODED_LENGTH: usize = 32; -const MAX_HOLDER_KEY_ID_BYTES: usize = 256; - -impl HolderPublicKey { - /// Accept only a public OKP Ed25519 JWK whose coordinate is the canonical - /// unpadded base64url encoding of exactly 32 bytes. - pub fn is_acceptable(&self) -> bool { - if self.kty != "OKP" || self.crv != "Ed25519" { - return false; - } - if self.alg.as_deref().is_some_and(|alg| alg != "EdDSA") { - return false; - } - if self - .kid - .as_deref() - .is_some_and(|kid| kid.is_empty() || kid.len() > MAX_HOLDER_KEY_ID_BYTES) - { - return false; - } - URL_SAFE_NO_PAD - .decode(&self.x) - .is_ok_and(|decoded| decoded.len() == HOLDER_KEY_DECODED_LENGTH) - } -} - /// Requester-scoped descriptions of the exact Evidence request shapes that /// the authenticated caller can currently invoke. #[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] @@ -201,62 +171,6 @@ pub enum SelectorValue { Boolean(bool), } -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Evidence { - pub schema: String, - pub assurance_profile: AssuranceProfile, - /// Exact echo of the caller's request nonce for request-response - /// correlation. The runtime does not store it or reject reuse. - pub request_nonce: String, - pub id: String, - #[serde(rename = "type")] - pub evidence_type_name: EvidenceObjectType, - pub supports_requirement: String, - pub is_conformant_to: String, - pub issued_by: String, - pub provided_by: String, - pub issued_at: String, - pub observed_at: String, - pub valid_until: String, - pub purpose: String, - pub audience: String, - pub configuration_revision: String, - pub subjects: Vec, - pub supported_values: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -pub enum EvidenceObjectType { - Evidence, -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectBinding { - pub role: String, - pub binding: String, -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SupportedValue { - pub provides_value_for: String, - pub value: PublicValue, -} - -#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema, ToSchema)] -#[serde(untagged)] -pub enum PublicValue { - Boolean(bool), - Integer(i64), - String(String), - Bucket(BucketValue), - EntityReference(EntityReferenceValue), - Structured(StructuredValue), - List(Vec), -} - impl<'de> Deserialize<'de> for SelectorValue { fn deserialize(deserializer: D) -> Result where @@ -273,158 +187,6 @@ impl<'de> Deserialize<'de> for SelectorValue { } } -impl<'de> Deserialize<'de> for PublicValue { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = Value::deserialize(deserializer)?; - match value { - Value::Bool(value) => Ok(Self::Boolean(value)), - Value::Number(value) => safe_json_integer(&value) - .map(Self::Integer) - .ok_or_else(|| de::Error::custom("public number is not a safe JSON integer")), - Value::String(value) => Ok(Self::String(value)), - Value::Array(values) => serde_json::from_value(Value::Array(values)) - .map(Self::List) - .map_err(de::Error::custom), - Value::Object(object) => { - let form = object - .get("form") - .and_then(Value::as_str) - .map(str::to_owned); - let value = Value::Object(object); - match form.as_deref() { - Some("date-bucket" | "time-bucket") => serde_json::from_value(value) - .map(Self::Bucket) - .map_err(de::Error::custom), - Some("audience-scoped-entity-reference") => serde_json::from_value(value) - .map(Self::EntityReference) - .map_err(de::Error::custom), - Some("reviewed-structured-value") => serde_json::from_value(value) - .map(Self::Structured) - .map_err(de::Error::custom), - _ => Err(de::Error::custom("public object has an unsupported form")), - } - } - Value::Null => Err(de::Error::custom("public value cannot be null")), - } - } -} - -fn safe_json_integer(number: &Number) -> Option { - const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; - - if let Some(value) = number.as_i64() { - return (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER) - .contains(&value) - .then_some(value); - } - if let Some(value) = number.as_u64() { - return (value <= MAX_SAFE_INTEGER as u64).then_some(value as i64); - } - let value = number.as_f64()?; - (value.is_finite() - && value.fract() == 0.0 - && value >= -(MAX_SAFE_INTEGER as f64) - && value <= MAX_SAFE_INTEGER as f64) - .then_some(value as i64) -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(untagged)] -pub enum ScalarOrEntityReference { - String(String), - EntityReference(EntityReferenceValue), -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BucketValue { - pub form: BucketForm, - pub scheme: String, - pub bucket: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum BucketForm { - DateBucket, - TimeBucket, -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EntityReferenceValue { - pub form: EntityReferenceForm, - pub reference: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum EntityReferenceForm { - AudienceScopedEntityReference, -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct StructuredValue { - pub form: StructuredValueForm, - pub schema: String, - pub fields: BTreeMap, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum StructuredValueForm { - ReviewedStructuredValue, -} - -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct FlattenedJws { - pub protected: String, - pub payload: String, - pub signature: String, -} - -/// Self-identifying unsigned response envelope. It deliberately does not -/// serialize as the signed Evidence payload by itself and carries no JWS -/// member, so the strict JWS verifier rejects it. -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct UnsignedEvidenceEnvelope { - pub schema: String, - #[serde(rename = "type")] - pub envelope_type: UnsignedEnvelopeType, - pub integrity_protection: UnsignedIntegrityProtection, - pub warning: UnsignedEnvelopeWarning, - pub evidence: Evidence, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -pub enum UnsignedEnvelopeType { - UnsignedEvidenceEnvelope, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum UnsignedIntegrityProtection { - None, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum UnsignedEnvelopeWarning { - NotCryptographicallyVerifiable, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct JwksDocument { - pub keys: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] #[serde(deny_unknown_fields)] pub struct ProblemBody { @@ -443,24 +205,8 @@ pub enum LookupResult { Ambiguous, } -macro_rules! redacted_debug { - ($($type_name:ty),+ $(,)?) => { - $( - impl fmt::Debug for $type_name { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct(stringify!($type_name)) - .field("protected", &"") - .finish() - } - } - )+ - }; -} - redacted_debug!( EvidenceRequest, - HolderPublicKey, EvidenceDefinitions, EvidenceDefinition, EvidenceDefinitionSubject, @@ -470,16 +216,6 @@ redacted_debug!( RequestedSubject, RequestedSelector, SelectorValue, - Evidence, - SubjectBinding, - SupportedValue, - PublicValue, - ScalarOrEntityReference, - BucketValue, - EntityReferenceValue, - StructuredValue, - FlattenedJws, - UnsignedEvidenceEnvelope, LookupResult, ); diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs index 16831dc86..c59520b32 100644 --- a/crates/registry-evidence/tests/security_contract_traceability.rs +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -245,10 +245,16 @@ fn every_sd_jwt_vc_profile_negative_is_bound_to_a_mapped_security_negative() { /// Prove that one mapped reference still names a real Rust test item, so a /// renamed, moved, or deleted test fails the traceability checker. fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &TestReference) { + // Evidence source is the runtime crate and the portable verifier crate + // beside it. A reference may name either of those trees and nothing else. + let inside_evidence_source = [ + "crates/registry-evidence/", + "crates/registry-evidence-verifier/", + ] + .iter() + .any(|tree| test.file.starts_with(tree)); assert!( - test.file.starts_with("crates/registry-evidence/") - && test.file.ends_with(".rs") - && !test.file.contains(".."), + inside_evidence_source && test.file.ends_with(".rs") && !test.file.contains(".."), "{entry_id} has an unsafe source reference" ); let source = fs::read_to_string(root.join(&test.file)) diff --git a/products/evidence/contracts/acceptance-test-traceability.yaml b/products/evidence/contracts/acceptance-test-traceability.yaml index 449269c16..516f26606 100644 --- a/products/evidence/contracts/acceptance-test-traceability.yaml +++ b/products/evidence/contracts/acceptance-test-traceability.yaml @@ -58,13 +58,13 @@ entries: - id: acceptance-row-11 summary: JWS verification fails after any protected-header or payload mutation. tests: - - {file: crates/registry-evidence/src/verifier.rs, name: payload_and_protected_header_mutation_fail} - - {file: crates/registry-evidence/src/verifier.rs, name: duplicate_jws_members_and_unknown_kid_are_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: complete_jws_negative_fixture_is_executable} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: payload_and_protected_header_mutation_fail} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: duplicate_jws_members_and_unknown_kid_are_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: complete_jws_negative_fixture_is_executable} - id: acceptance-row-12 summary: A valid false boolean result is a success in either authorized response format, not an error. tests: - - {file: crates/registry-evidence/src/verifier.rs, name: signed_false_round_trips_and_verifies} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: signed_false_round_trips_and_verifies} - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} - {file: crates/registry-evidence/src/runtime_tests.rs, name: all_four_definitions_pass_the_explicitly_authorized_unsigned_path} - id: acceptance-row-13 @@ -192,7 +192,7 @@ entries: - {file: crates/registry-evidence/src/kernel.rs, name: supported_value_fixture_global_negatives_are_enforced} - {file: crates/registry-evidence/src/kernel.rs, name: scalar_decimal_and_collection_forms_are_exact} - {file: crates/registry-evidence/src/kernel.rs, name: bucket_entity_and_structured_forms_are_closed} - - {file: crates/registry-evidence/src/verifier.rs, name: signed_schema_integer_lexical_forms_verify_without_type_loss} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: signed_schema_integer_lexical_forms_verify_without_type_loss} - id: acceptance-row-30 summary: source-derived, field-projected, and record-transformed definitions use the same executor and report their acquisition guarantees honestly. tests: @@ -347,16 +347,16 @@ entries: - id: acceptance-row-53 summary: Signed Evidence echoes the exact request nonce; changing the expected or signed nonce fails verification, and nonce reuse is not represented as replay prevention. tests: - - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} - {file: crates/registry-evidence/src/runtime_tests.rs, name: request_nonce_is_strict_and_never_reaches_source_or_audit} - id: acceptance-row-54 summary: The verifier requires independently trusted expected subject roles and opaque bindings plus expected concept identifiers, forms, and cardinalities after signature verification; missing, extra, duplicated, substituted, or wrong-key-version bindings and unexpected output fail, and subject order alone is non-semantic. tests: - - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} - - {file: crates/registry-evidence/src/verifier.rs, name: signature_never_substitutes_for_provider_and_issuer_trust_policy} - - {file: crates/registry-evidence/src/verifier.rs, name: retired_public_key_verifies_only_while_published_and_payload_is_current} - - {file: crates/registry-evidence/src/verifier.rs, name: active_plus_maximum_retired_keys_is_a_usable_trusted_set} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: signature_never_substitutes_for_provider_and_issuer_trust_policy} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: retired_public_key_verifies_only_while_published_and_payload_is_current} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: active_plus_maximum_retired_keys_is_a_usable_trusted_set} - id: acceptance-row-55 summary: Missing Accept, */*, and exact signed media select JWS; only exact unsigned media selects unsigned JSON, and duplicate, combined, parameterized, weighted, or unknown negotiation fails before source access. tests: @@ -379,7 +379,7 @@ entries: summary: The unsigned envelope has its exact vendor media type, schema, type, integrity marker, warning, and closed nested Evidence with no protected, payload, signature, or signing-key claim, and the JWS verifier rejects it. tests: - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} - - {file: crates/registry-evidence/src/verifier.rs, name: unsigned_envelope_is_rejected_by_the_strict_jws_verifier} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: unsigned_envelope_is_rejected_by_the_strict_jws_verifier} - {file: crates/registry-evidence/src/contracts.rs, name: schemas_accept_the_exact_public_wire_shapes} - {file: crates/registry-evidence/src/contracts.rs, name: jws_and_problem_schemas_are_closed} - id: acceptance-row-59 @@ -400,12 +400,12 @@ entries: - id: acceptance-row-61 summary: Verification tooling re-verifies a stored signed response against a pinned trusted key, expected policy, request nonce, subject bindings, and output contract, and reports cryptographic authenticity separately from current validity. tests: - - {file: crates/registry-evidence/src/verifier.rs, name: authenticity_is_reported_separately_from_current_validity} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} - - {file: crates/registry-evidence/src/verifier.rs, name: assertion_lifetime_above_the_accepted_maximum_fails} - - {file: crates/registry-evidence/src/verifier.rs, name: complete_jws_negative_fixture_is_executable} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: authenticity_is_reported_separately_from_current_validity} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: assertion_lifetime_above_the_accepted_maximum_fails} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: complete_jws_negative_fixture_is_executable} - {file: crates/registry-evidence/tests/cli.rs, name: verify_accepts_an_authentic_and_current_stored_response} - {file: crates/registry-evidence/tests/cli.rs, name: verify_separates_authenticity_from_current_validity} - {file: crates/registry-evidence/tests/cli.rs, name: verify_rejects_a_tampered_payload_without_naming_a_value} diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 5f96b99f3..d85df10d3 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -10,7 +10,7 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: local_runtime_without_fixture_references_keeps_the_real_security_path} - id: expected-assurance-profile-mismatch tests: - - {file: crates/registry-evidence/src/verifier.rs, name: authentic_local_assertions_fail_deployable_assurance_expectations} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: authentic_local_assertions_fail_deployable_assurance_expectations} - id: sec-unknown-or-disabled-requirement tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: security_contract_rejects_unknown_and_unauthorized_requests_before_source_access}] - id: sec-caller-query-material-rejected @@ -86,9 +86,9 @@ entries: tests: [{file: crates/registry-evidence/src/config.rs, name: a_shared_disclosure_family_rejects_the_complete_bundle}] - id: sec-jws-mutation-and-duplicate-payload tests: - - {file: crates/registry-evidence/src/verifier.rs, name: payload_and_protected_header_mutation_fail} - - {file: crates/registry-evidence/src/verifier.rs, name: duplicate_jws_members_and_unknown_kid_are_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: signed_payload_must_satisfy_the_complete_evidence_schema} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: payload_and_protected_header_mutation_fail} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: duplicate_jws_members_and_unknown_kid_are_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: signed_payload_must_satisfy_the_complete_evidence_schema} - id: sec-signing-failure-no-release tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: signing_failure_is_transient_audited_and_never_releases_unsigned_evidence}] - id: sec-private-key-canary-unreachable @@ -96,7 +96,7 @@ entries: - {file: crates/registry-evidence/src/config.rs, name: yaml_names_and_secret_references_are_strict} - {file: crates/registry-evidence/src/signing.rs, name: jwks_contains_public_material_only} - id: sec-verifier-rejects-untrusted-provider - tests: [{file: crates/registry-evidence/src/verifier.rs, name: signature_never_substitutes_for_provider_and_issuer_trust_policy}] + tests: [{file: crates/registry-evidence-verifier/src/verifier.rs, name: signature_never_substitutes_for_provider_and_issuer_trust_policy}] - id: sec-script-cannot-construct-evidence tests: - {file: crates/registry-evidence/src/rhai_runtime.rs, name: ambient_and_diagnostic_capabilities_are_unavailable} @@ -169,7 +169,7 @@ entries: tests: - {file: crates/registry-evidence/src/runtime_tests.rs, name: multi_role_request_order_is_not_semantic_and_output_uses_declaration_order} - {file: crates/registry-evidence/src/runtime_tests.rs, name: reordered_grant_subjects_resolve_by_role_and_emit_declaration_order} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} - id: sec-request-nonce-strict-and-contained tests: - {file: crates/registry-evidence/src/model.rs, name: request_nonce_canonicality_is_exact} @@ -195,14 +195,14 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_format_not_permitted_by_grant} - id: sec-verifier-independent-expectations tests: - - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} - - {file: crates/registry-evidence/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} - - {file: crates/registry-evidence/src/verifier.rs, name: authenticity_is_reported_separately_from_current_validity} - - {file: crates/registry-evidence/src/verifier.rs, name: assertion_lifetime_above_the_accepted_maximum_fails} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: authenticity_is_reported_separately_from_current_validity} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: assertion_lifetime_above_the_accepted_maximum_fails} - id: sec-unsigned-envelope-not-jws tests: - - {file: crates/registry-evidence/src/verifier.rs, name: unsigned_envelope_is_rejected_by_the_strict_jws_verifier} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: unsigned_envelope_is_rejected_by_the_strict_jws_verifier} - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} - id: sec-secret-file-identity tests: @@ -228,14 +228,14 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation} - id: sec-sd-jwt-projection-integrity tests: - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_vc_round_trips_and_verifies_under_the_same_policy} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_disclosure_modification_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_added_disclosure_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_removed_digest_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_payload_modification_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_protected_header_modification_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_unknown_kid_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_rejected_by_flattened_jws_verifier} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_vc_round_trips_and_verifies_under_the_same_policy} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_disclosure_modification_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_added_disclosure_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_removed_digest_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_payload_modification_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_protected_header_modification_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_unknown_kid_rejected} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_rejected_by_flattened_jws_verifier} - {file: crates/registry-evidence/tests/cli.rs, name: verify_accepts_an_authentic_and_current_stored_sd_jwt_vc} - {file: crates/registry-evidence/tests/cli.rs, name: verify_rejects_a_stored_sd_jwt_vc_whose_disclosure_was_replaced} - {file: crates/registry-evidence/tests/cli.rs, name: verify_requires_exactly_one_stored_response_format} @@ -248,9 +248,9 @@ entries: tests: - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_holder_key_with_private_member_rejected} - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_holder_key_wrong_algorithm_rejected} - - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_vc_confirmation_is_accepted_and_carries_no_private_material} + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_vc_confirmation_is_accepted_and_carries_no_private_material} - id: sec-sd-jwt-claims-closed - tests: [{file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_prohibited_claim_rejected}] + tests: [{file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_prohibited_claim_rejected}] - id: sec-local-source-none-boundary tests: - {file: crates/registry-evidence/src/config.rs, name: unauthenticated_source_is_local_loopback_only_and_matches_the_bundle_schema} diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index 561a5c431..cffbb563d 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -10,6 +10,7 @@ production_text="$temporary_root/production-rust.txt" for source_file in $( rg --files \ "$repository_root/crates/registry-evidence/src" \ + "$repository_root/crates/registry-evidence-verifier/src" \ "$repository_root/crates/registry-evidencectl/src" \ -g '*.rs' | sort ); do @@ -133,6 +134,7 @@ done if rg -n -i 'dhis2|opencrvs' \ "$production_text" \ "$repository_root/crates/registry-evidence/Cargo.toml" \ + "$repository_root/crates/registry-evidence-verifier/Cargo.toml" \ "$repository_root/crates/registry-evidencectl/Cargo.toml" \ "$repository_root/Cargo.toml"; then echo 'Evidence production code, adopter tooling, or Cargo metadata contains a prohibited source-product name.' >&2 From d8bbeda5a2d4366d369dbbd3e81eae87d5dbe72b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 00:00:49 +0700 Subject: [PATCH 02/67] docs(evidence): name the verifier crate in the Version 1 boundary The Evidence Version 1 boundary statements named one `registry-evidence` crate and one `evidence` binary with no room for a library beside the runtime. Name `registry-evidence-verifier` in every one of those statements so the tracked product material describes the arrangement as it stands. The framing is the same everywhere: the runtime is still one `registry-evidence` crate and one `evidence` binary; `registry-evidence-verifier` is the portable response-verification library the runtime depends on, and it exists so client tooling can verify a signed Evidence response without the runtime. It is a library, not a second runtime, and not a pattern of its own. Amended: the Version-one release scope and fixed decision 12 in `products/evidence/CONCEPT.md`, the product boundary in `products/evidence/README.md` and `products/evidence/AGENTS.md`, the Evidence product boundary and repository map in the root `AGENTS.md`, the service surface in `spec/rs-pr-evidence`, the Evidence architecture section in `spec/rs-arc-g`, the crate README, and the Evidence project record in `docs/site/src/data/projects.yaml`. `docs/site/src/data/generated/projects.json` is regenerated with `npm run generate`. `products/evidence/IMPLEMENTATION.md` keeps its phase-one wording, which describes a schedule deliverable rather than the product boundary. Signed-off-by: Jeremi Joslin --- AGENTS.md | 8 ++++++++ crates/registry-evidence/README.md | 5 ++++- docs/site/src/content/docs/spec/rs-arc-g.mdx | 2 +- docs/site/src/content/docs/spec/rs-pr-evidence.mdx | 4 +++- docs/site/src/data/generated/projects.json | 2 +- docs/site/src/data/projects.yaml | 2 +- products/evidence/AGENTS.md | 6 ++++++ products/evidence/CONCEPT.md | 9 +++++++-- products/evidence/README.md | 14 +++++++++----- 9 files changed, 40 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aedfc5ed8..8a968d42f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Evidence's authenticator; Evidence does not depend on Mint. |---|---| | `crates/registry-relay` | Protected read APIs (Relay) | | `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | +| `crates/registry-evidence-verifier` | Portable Evidence response verification, shared by the runtime and client tooling | | `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, incomplete OpenAPI authoring workspaces, fixture runs for complete projects | | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | @@ -52,6 +53,13 @@ binary. It may reuse narrowly applicable `registry-platform-*` primitives such as audit, crypto, OIDC, HTTP security, SD-JWT serialization, and testing. +`registry-evidence-verifier` is the portable response-verification library the +runtime depends on. It owns the response wire formats, the Evidence payload +contract, and relying-party verification, so client tooling can verify a signed +Evidence response without the runtime. It is a library, not a second runtime and +not a pattern of its own, and it carries no server, source access, or +platform-specific dependency. + `registry-evidencectl` (`evidencectl`) is adopter tooling beside the runtime, like `registryctl` is for the rest of the stack. It sits outside the frozen Version 1 runtime contract: it generates key material, starts incomplete diff --git a/crates/registry-evidence/README.md b/crates/registry-evidence/README.md index 2616cc318..613927bf7 100644 --- a/crates/registry-evidence/README.md +++ b/crates/registry-evidence/README.md @@ -3,7 +3,10 @@ `registry-evidence` is the single-crate Evidence Version 1 runtime. It loads one immutable operator-controlled bundle, evaluates fixed requirements through bounded Rhai extraction and derivation, and returns minimum-disclosure -assertion evidence. +assertion evidence. It depends on +[`registry-evidence-verifier`](../registry-evidence-verifier/README.md) for the +response formats, the Evidence payload contract, and relying-party verification, +and serves those items at its own paths. The `evidence` binary takes a runtime file and one subcommand: diff --git a/docs/site/src/content/docs/spec/rs-arc-g.mdx b/docs/site/src/content/docs/spec/rs-arc-g.mdx index 91bd3a3ec..e38e93aa8 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -143,7 +143,7 @@ Registry Relay is not an open-data portal; it serves restricted consultation API ### Evidence Gateway -Evidence Gateway (`registry-evidence`) is a single-crate Rust service and the `evidence` binary. It answers one bounded question about one subject with one signed assertion that carries the answer and not the record. It loads one reviewed governed bundle and one closed operator runtime file at startup, treats both as immutable for the process lifetime, and holds no application database: it persists no selector, no source payload, no evidence value, and no response body. It authenticates callers with strict OIDC bearer tokens against exactly one trusted issuer, matches exactly one authority path per request, calls the fixed bounded HTTP JSON sources its bundle declares, and releases the assertion as a flattened JWS JSON document by default. Where the bundle and the matched grant both enable it, the same assertion can also be serialized as an SD-JWT VC (`application/dc+sd-jwt`) or as a visibly unsigned envelope for local diagnosis. +Evidence Gateway (`registry-evidence`) is a single-crate Rust service and the `evidence` binary, depending on the portable `registry-evidence-verifier` library for the response formats, the Evidence payload contract, and relying-party verification, so client tooling can verify a signed response without the service. Evidence Gateway answers one bounded question about one subject with one signed assertion that carries the answer and not the record. It loads one reviewed governed bundle and one closed operator runtime file at startup, treats both as immutable for the process lifetime, and holds no application database: it persists no selector, no source payload, no evidence value, and no response body. It authenticates callers with strict OIDC bearer tokens against exactly one trusted issuer, matches exactly one authority path per request, calls the fixed bounded HTTP JSON sources its bundle declares, and releases the assertion as a flattened JWS JSON document by default. Where the bundle and the matched grant both enable it, the same assertion can also be serialized as an SD-JWT VC (`application/dc+sd-jwt`) or as a visibly unsigned envelope for local diagnosis. Evidence Gateway Version 1 has no credential issuance lifecycle, no OID4VCI surface, no holder binding, no status list or revocation, no delegated or federated evaluation between peers, no policy decision point, no replay subsystem, and no worker or document subsystem. It does not depend on Registry Relay, does not consult it, and does not read the metadata manifest. diff --git a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx index 6739f3cf2..f5858ae9c 100644 --- a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx @@ -107,7 +107,9 @@ inherits no requirement from it. ## 2. Service surface and discovery Evidence Gateway is one `registry-evidence` crate, one `evidence` binary, one serving process, and one -operator-controlled trust domain (`products/evidence/README.md`). +operator-controlled trust domain, beside the portable `registry-evidence-verifier` library the +runtime depends on for the response formats, the Evidence payload contract, and relying-party +verification (`products/evidence/README.md`). REQ-PR-EVIDENCE-001: The reachable HTTP surface MUST be exactly the operations declared in `products/evidence/generated/registry-evidence.openapi.json`: one evidence operation, one diff --git a/docs/site/src/data/generated/projects.json b/docs/site/src/data/generated/projects.json index 2e3a61d52..8299ae240 100644 --- a/docs/site/src/data/generated/projects.json +++ b/docs/site/src/data/generated/projects.json @@ -124,7 +124,7 @@ "url": "https://github.com/registrystack/registry-stack/blob/HEAD/products/evidence/OPERATOR-CONTRACT.md" } ], - "rename_status": "Developed in the Registry Stack monorepo as one `registry-evidence` crate; no separate repository." + "rename_status": "Developed in the Registry Stack monorepo as one `registry-evidence` crate beside the portable `registry-evidence-verifier` verification library; no separate repository." }, { "id": "registry-mint", diff --git a/docs/site/src/data/projects.yaml b/docs/site/src/data/projects.yaml index 97ea367aa..3bc426212 100644 --- a/docs/site/src/data/projects.yaml +++ b/docs/site/src/data/projects.yaml @@ -86,7 +86,7 @@ url: https://github.com/registrystack/registry-stack/blob/HEAD/products/evidence/README.md - label: Operator contract url: https://github.com/registrystack/registry-stack/blob/HEAD/products/evidence/OPERATOR-CONTRACT.md - rename_status: Developed in the Registry Stack monorepo as one `registry-evidence` crate; no separate repository. + rename_status: Developed in the Registry Stack monorepo as one `registry-evidence` crate beside the portable `registry-evidence-verifier` verification library; no separate repository. - id: registry-mint name: Registry Mint repo_path: ../registry-stack diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index f92a4e77c..6c1292ce8 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -25,6 +25,12 @@ Registry Notary and must not depend on or copy abstractions from `registry-platform-pdp`, `registry-platform-oid4vci`, `registry-platform-replay`, or `registry-platform-sts`. +The runtime depends on the portable `registry-evidence-verifier` library, which +owns the response formats, the Evidence payload contract, and relying-party +verification so client tooling can verify a signed response without the runtime. +The verifier library sits beside the runtime and is not a second runtime; its +source is covered by the same source-product and domain neutrality checks. + Selected `registry-platform-*` primitives may be reused only when their existing contracts fit Evidence directly. The approved candidates are audit, crypto, OIDC, HTTP security, testing, and the `registry-platform-sdjwt` serialization diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 11ce05756..9f4bb7fe6 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -1345,7 +1345,10 @@ derivation, special route, or preferred implementation order. Version one is one synchronous assertion service with signed JWS as the mandatory default and includes: -- one `registry-evidence` crate, one `evidence` binary, and one serving process; +- one `registry-evidence` crate, one `evidence` binary, and one serving process, + with the portable `registry-evidence-verifier` library the runtime depends on + for the response formats, the payload contract, and relying-party + verification; - one operator-controlled trust domain; - all four initial assertion cases as complete test-only acceptance bundles; - conformance fixtures for every Version 1 Supported Value form; @@ -1620,7 +1623,9 @@ This concept fixes the following decisions: envelope only when the immutable bundle and complete matched grant permit it. No signed-path failure falls back to unsigned output. 11. One process serves one operator-controlled trust domain. -12. The reference implementation is one `registry-evidence` crate and one `evidence` binary. +12. The reference implementation is one `registry-evidence` crate and one `evidence` binary, + beside the portable `registry-evidence-verifier` response-verification + library the runtime depends on. The library is not a second runtime. 13. The governed evidence bundle is the disclosure-review boundary; closed runtime bindings cannot override it. 14. General identity resolution, broad candidate retrieval, scoring or diff --git a/products/evidence/README.md b/products/evidence/README.md index d4bc8db3b..c54760cbe 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -10,11 +10,15 @@ the smallest sufficient JSON assertion in an authorized response format. The approved Version 1 product boundary is one `registry-evidence` crate, one `evidence` binary, one serving process, and one operator-controlled trust domain. -A process may host multiple evidence definitions only when they share that trust -domain. Governed configuration, Rhai scripts, schemas, codelists, and fixtures -are one trusted, immutable, startup-only evidence bundle. A separate closed -runtime file owns only process-local listener, filesystem, audit-storage, -secret-mount, and TLS-trust bindings and cannot override governed semantics. +The runtime depends on one portable library beside it, +`registry-evidence-verifier`, which owns the response formats, the Evidence +payload contract, and relying-party verification, so client tooling can verify a +signed response without the runtime. A process may host multiple evidence +definitions only when they share that trust domain. Governed configuration, Rhai +scripts, schemas, codelists, and fixtures are one trusted, immutable, +startup-only evidence bundle. A separate closed runtime file owns only +process-local listener, filesystem, audit-storage, secret-mount, and TLS-trust +bindings and cannot override governed semantics. The following contracts define and verify the implemented Version 1 boundary: From 88a5c18c8651d257f8bf813cc8c2d4b0f2693f69 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 00:29:32 +0700 Subject: [PATCH 03/67] fix(evidence): tighten verifier fixture fidelity and guard portability The portable verifier crate's test-only issuer fixtures now enforce the same maximum published key count as the runtime signer, with the same check-before-push semantics, so a fixture-built trusted key set cannot exceed what a deployment can serve. A focused test proves the bound is live. The module doc now states exactly what the fixtures mirror (protected header bytes, signing input, SD-JWT VC issuance shape, published key count) and which issuer-side configuration guards they deliberately omit (key identifier validation, the check that the published key repeats the provider's algorithm and key identifier, and the startup sign-and-verify self-test), with the reason those omissions are safe for in-process fixtures built from a known good test key. The exported redacted_debug! macro now writes ::core::fmt paths, so its expansion no longer depends on the calling module having std::fmt in scope. The doc sentence that stated that requirement is gone, and both callers dropped the fmt import that only the macro needed. products/evidence/scripts/check-verifier-portability.sh refuses a normal dependency tree for registry-evidence-verifier that reaches axum, clap, fs2, hyper, mio, reqwest, rhai, rustix, socket2, tokio, or tracing, over every target. CI runs it in the Evidence contracts job beside the contract and source-neutrality checks, and the crate README and the Evidence final gate list it. The Rust shard inventory in .github/scripts/ci_changes.py did not name registry-evidence-verifier, which failed test_shards_cover_every_workspace_package_once and left the crate outside every change-classified gate. The crate now sits in the evidence shard, so a verifier-only change runs the Evidence Rust tests and the Evidence contracts job. Security review notes: - No verification, signing, evaluation, authentication, authorization, audit, or data-minimization behavior changes. The fixture bound and the macro path qualification are compile-time and test-only concerns; production wire types keep the same redacted Debug output. - The added test key material is a public JWK only, the public half of the Ed25519 test key already committed for these suites. No private key, token, or credential is added. - The portability guard is a supply-chain control for a crate that client tooling will link: it keeps response verification free of an async runtime, an HTTP client, a script engine, a command line parser, and a logging framework, so a verifier cannot acquire a network or logging surface by transitive dependency. One correction to the extraction's "pure code motion" framing: contracts::evidence_contract_accepts is an inlining, not a move. The base delegated it to a shared contract_validator helper that memoized three validators behind three OnceLock cells. The portable crate needs only the Evidence payload validator, so that helper's body is inlined into the function against the crate's own EVIDENCE_VALIDATOR cell. The draft, the should_validate_formats(true) setting, the compiled schema, the error mapping, and the returned Result are the same, so the accept-or-refuse decision is identical. The helper itself stays in the runtime crate, where request_contract_accepts and definitions_contract_accepts still call it. Corrected widening inventory for the extraction, superseding the notes in "refactor(evidence): extract portable response verifier crate": - verifier.rs: 26 items went pub(crate) to pub, unchanged in behavior. - contracts.rs: SCHEMA_DIALECT, EVIDENCE_SCHEMA_ID, REQUEST_NONCE_PATTERN, and evidence_schema went private to pub; ContractValidationError and evidence_contract_accepts went pub(crate) to pub. - model.rs: safe_json_integer went private to pub, not pub(crate) to pub, and redacted_debug! gained #[macro_export]. The base declared it as a plain macro_rules! reachable only inside the model module, so exporting it is a crate-root reachability widening. The runtime's model module needs that reach to keep applying the macro to the wire types it still declares. The macro body is unchanged apart from the ::core::fmt qualification above, and no registry_evidence::redacted_debug path exists, so the runtime crate's own surface does not grow. - sdjwt_vc.rs and the crate root widen nothing beyond that exported macro. The moved payload helpers, the response media type and header constants, AssuranceProfile, and the module declarations that carry them were already pub. - registry-evidence serves every widened name at the path it already had. The verifier and sdjwt_vc modules and the crate-root constants come back through a pub use in lib.rs, the model types through a pub use in model.rs, and AssuranceProfile through a pub use in config.rs. The five contracts.rs names the runtime reads outside its tests come back as a pub(crate) use, and contracts::evidence_contract_accepts comes back under #[cfg(test)] only, so no such path exists in a non-test build of the runtime crate. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 6 ++- .github/workflows/ci.yml | 3 ++ crates/registry-evidence-verifier/README.md | 5 ++ .../src/fixtures.rs | 51 +++++++++++++++++- .../registry-evidence-verifier/src/model.rs | 10 ++-- crates/registry-evidence/src/model.rs | 2 +- products/evidence/AGENTS.md | 1 + .../scripts/check-verifier-portability.sh | 53 +++++++++++++++++++ 8 files changed, 123 insertions(+), 8 deletions(-) create mode 100755 products/evidence/scripts/check-verifier-portability.sh diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 6f39db9e4..207042ec8 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -31,7 +31,11 @@ "registry-manifest-core", ), "relay": ("registry-relay",), - "evidence": ("registry-evidence", "registry-evidencectl"), + "evidence": ( + "registry-evidence", + "registry-evidence-verifier", + "registry-evidencectl", + ), "mint": ("registry-mint",), "developer-tools": ( "registry-config-report", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d56b884..24cb7441b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,6 +460,9 @@ jobs: - name: Enforce Evidence source-product neutrality run: products/evidence/scripts/check-source-neutrality.sh + - name: Enforce Evidence verifier portability + run: products/evidence/scripts/check-verifier-portability.sh + relay-contracts: name: Relay API contracts needs: changes diff --git a/crates/registry-evidence-verifier/README.md b/crates/registry-evidence-verifier/README.md index 4504352a8..74d8e729d 100644 --- a/crates/registry-evidence-verifier/README.md +++ b/crates/registry-evidence-verifier/README.md @@ -61,8 +61,13 @@ with cryptographic authenticity reported separately from current validity. ```sh cargo test -p registry-evidence-verifier +products/evidence/scripts/check-verifier-portability.sh ``` +The second command proves the normal dependency tree still carries no async +runtime, HTTP stack, script engine, command line parser, or logging framework, +so client tooling can link this crate on any platform. + ## License Apache-2.0. diff --git a/crates/registry-evidence-verifier/src/fixtures.rs b/crates/registry-evidence-verifier/src/fixtures.rs index fb8784f02..13c783d26 100644 --- a/crates/registry-evidence-verifier/src/fixtures.rs +++ b/crates/registry-evidence-verifier/src/fixtures.rs @@ -3,8 +3,17 @@ //! The Evidence runtime owns signing and depends on this crate, so a test here //! cannot reach the runtime signer: a development dependency back onto the //! runtime would link a second instance of this crate and its wire types would -//! not unify. These fixtures produce authentic signed inputs from the same -//! protected header, key set, and SD-JWT VC issuance rules instead. The runtime +//! not unify. These fixtures produce authentic signed inputs instead, and +//! mirror the parts of issuance that verification reads: the protected header +//! bytes, the signing input, the SD-JWT VC issuance shape, and the bound on the +//! number of published keys. +//! +//! They deliberately omit the runtime's issuer-side configuration guards: key +//! identifier validation, the check that the published key repeats the +//! provider's algorithm and key identifier, and the startup sign-and-verify +//! self-test. Each of those refuses a misconfigured deployment before it signs +//! anything, and a fixture signer is built in-process from a known good test +//! key, so their absence cannot weaken what these tests prove. The runtime //! signer is verified against this crate by the runtime's own suite. use std::{collections::BTreeSet, sync::Arc}; @@ -23,6 +32,8 @@ use crate::{ /// generate a fresh random value for every request. pub const OFFLINE_EVALUATION_REQUEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const MAX_PUBLISHED_KEYS: usize = 33; + #[derive(Debug)] pub enum FixtureSigningError { Algorithm, @@ -113,6 +124,9 @@ pub fn jwks_document( let mut seen = BTreeSet::new(); let mut keys = Vec::new(); for key in std::iter::once(active).chain(retired) { + if keys.len() == MAX_PUBLISHED_KEYS { + return Err(FixtureSigningError::PublishedKey); + } if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { return Err(FixtureSigningError::Algorithm); } @@ -127,3 +141,36 @@ pub fn jwks_document( } Ok(JwksDocument { keys }) } + +#[cfg(test)] +mod tests { + use super::*; + + const PUBLIC_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; + + /// The fixture key set publishes the same maximum number of keys as the + /// runtime, so a trusted set built here cannot exceed what a deployment can + /// serve. + #[test] + fn published_key_set_stops_at_the_runtime_bound() { + let active: PublicJwk = serde_json::from_str(PUBLIC_JWK).expect("test key parses"); + + let retired = (0..MAX_PUBLISHED_KEYS - 1).map(|index| { + let mut key = active.clone(); + key.kid = Some(format!("retired-evidence-key-{index:02}")); + key + }); + let boundary = jwks_document(active.clone(), retired).expect("the bound itself is allowed"); + assert_eq!(boundary.keys.len(), MAX_PUBLISHED_KEYS); + + let too_many = (0..MAX_PUBLISHED_KEYS).map(|index| { + let mut key = active.clone(); + key.kid = Some(format!("excess-evidence-key-{index:02}")); + key + }); + assert!(matches!( + jwks_document(active.clone(), too_many), + Err(FixtureSigningError::PublishedKey) + )); + } +} diff --git a/crates/registry-evidence-verifier/src/model.rs b/crates/registry-evidence-verifier/src/model.rs index f23ac8071..c785db24c 100644 --- a/crates/registry-evidence-verifier/src/model.rs +++ b/crates/registry-evidence-verifier/src/model.rs @@ -1,7 +1,7 @@ //! Response-side wire types: the Evidence payload, its public value forms, and //! the three response serializations a relying party can receive. -use std::{collections::BTreeMap, fmt}; +use std::collections::BTreeMap; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use schemars::JsonSchema; @@ -265,13 +265,15 @@ pub struct JwksDocument { /// Replace the derived `Debug` of a wire type with a redacted placeholder so /// disclosed material cannot reach a log line, a panic message, or a snapshot. -/// Callers must have `std::fmt` in scope. #[macro_export] macro_rules! redacted_debug { ($($type_name:ty),+ $(,)?) => { $( - impl fmt::Debug for $type_name { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + impl ::core::fmt::Debug for $type_name { + fn fmt( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { formatter .debug_struct(stringify!($type_name)) .field("protected", &"") diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index 09d78f4fb..598c3e028 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, fmt}; +use std::collections::BTreeMap; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use registry_evidence_verifier::{model::safe_json_integer, redacted_debug}; diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index 6c1292ce8..79023abc8 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -153,6 +153,7 @@ cargo test --locked --workspace cargo deny check products/evidence/scripts/check-contracts.sh products/evidence/scripts/check-source-neutrality.sh +products/evidence/scripts/check-verifier-portability.sh ``` Use the repository Cargo wrapper if one is added. Otherwise, in Codex-managed diff --git a/products/evidence/scripts/check-verifier-portability.sh b/products/evidence/scripts/check-verifier-portability.sh new file mode 100755 index 000000000..ce5bf1c7b --- /dev/null +++ b/products/evidence/scripts/check-verifier-portability.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Client tooling links the portable Evidence verifier to check a stored +# response, on any platform and without the runtime. Keep its normal dependency +# tree free of the async runtime, HTTP stack, script engine, command line +# parser, and logging framework that the runtime carries. + +CDPATH='' +repository_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd) + +forbidden_packages=( + axum + clap + fs2 + hyper + mio + reqwest + rhai + rustix + socket2 + tokio + tracing +) + +dependency_tree=$( + cargo tree \ + --locked \ + --manifest-path "$repository_root/Cargo.toml" \ + --package registry-evidence-verifier \ + --edges normal \ + --target all +) + +found_forbidden=0 +for package in "${forbidden_packages[@]}"; do + matches=$( + printf '%s\n' "$dependency_tree" | + rg "(^|[^0-9A-Za-z_-])${package}([-_][0-9A-Za-z_-]+)* v[0-9]" || true + ) + if [[ -n "$matches" ]]; then + printf 'registry-evidence-verifier reaches %s through its normal dependencies:\n%s\n' \ + "$package" "$matches" >&2 + found_forbidden=1 + fi +done + +if [[ "$found_forbidden" -ne 0 ]]; then + printf 'The portable Evidence verifier must stay free of runtime-only dependencies.\n' >&2 + exit 1 +fi + +printf 'Evidence verifier dependencies stay portable.\n' From 29f0735d321654af8ed68731d7bd7d3808385e66 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 00:50:28 +0700 Subject: [PATCH 04/67] fix(evidence): fail the verifier portability check on search errors The dependency search treated every non-zero search status as "no match", so a broken pattern or an unreadable tree would have reported a portable crate. Each package search now separates a match from a clean miss and refuses anything else, naming the package and the status. Root guidance lists the portability script beside the Evidence contract and source-neutrality checks, so a contributor running the documented commands runs the same gate CI runs. Signed-off-by: Jeremi Joslin --- AGENTS.md | 3 ++- .../scripts/check-verifier-portability.sh | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a968d42f..098fb7b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,11 +132,12 @@ Root CI's `rust` job runs `cargo fmt --check`, `cargo check --locked (`just openapi-contract` from `crates/registry-relay`). cargo-deny needs v0.19+ to parse this `deny.toml`; CI pins 0.19.8. -Evidence-specific contracts and source neutrality: +Evidence-specific contracts, source neutrality, and verifier portability: ```bash products/evidence/scripts/check-contracts.sh products/evidence/scripts/check-source-neutrality.sh +products/evidence/scripts/check-verifier-portability.sh ``` Release source checks: diff --git a/products/evidence/scripts/check-verifier-portability.sh b/products/evidence/scripts/check-verifier-portability.sh index ce5bf1c7b..79fa0ecdd 100755 --- a/products/evidence/scripts/check-verifier-portability.sh +++ b/products/evidence/scripts/check-verifier-portability.sh @@ -34,15 +34,26 @@ dependency_tree=$( found_forbidden=0 for package in "${forbidden_packages[@]}"; do + # A search that neither matches nor reports "no match" is a broken check, not + # a clean tree, so separate the two outcomes from every other status. + search_status=0 matches=$( printf '%s\n' "$dependency_tree" | - rg "(^|[^0-9A-Za-z_-])${package}([-_][0-9A-Za-z_-]+)* v[0-9]" || true - ) - if [[ -n "$matches" ]]; then + rg "(^|[^0-9A-Za-z_-])${package}([-_][0-9A-Za-z_-]+)* v[0-9]" + ) || search_status=$? + case "$search_status" in + 0) printf 'registry-evidence-verifier reaches %s through its normal dependencies:\n%s\n' \ "$package" "$matches" >&2 found_forbidden=1 - fi + ;; + 1) ;; + *) + printf 'The dependency search for %s failed with status %s.\n' \ + "$package" "$search_status" >&2 + exit 1 + ;; + esac done if [[ "$found_forbidden" -ne 0 ]]; then From 3bc2445ac4645b8f065939fd2c1adc1d81babd69 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 00:50:35 +0700 Subject: [PATCH 05/67] chore(release): declare the verifier portability gate The gate inventory now names the Evidence verifier portability step, so removing it from root CI fails the release gate check instead of passing unnoticed. Signed-off-by: Jeremi Joslin --- release/scripts/check-gates-inventory.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 6c38248c7..4cc15e482 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -163,6 +163,10 @@ "Evidence source neutrality", "run: products/evidence/scripts/check-source-neutrality.sh", ), + ( + "Evidence verifier portability", + "run: products/evidence/scripts/check-verifier-portability.sh", + ), ("Relay OpenAPI contract", "name: Relay OpenAPI contract"), ("Relay OpenAPI command", "run: just openapi-contract"), ("Relay exposure check", "name: Relay exposure check"), From d3bd1d4f926c817f231ade5922908991e039f8a3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 01:57:28 +0700 Subject: [PATCH 06/67] fix(evidence): align verifier portability claims and internal bounds The crate doc and the README overstated portability. Both claimed the crate has no platform-specific requirement and can be linked anywhere, but the crypto stack reaches aws-lc-sys through registry-platform-crypto and through registry-platform-sdjwt's jsonwebtoken, and aws-lc-sys builds native C. Both now say what the portability gate actually proves, which is freedom from the service runtime, and state the real constraint: a build needs a C toolchain and is limited to the targets aws-lc-sys supports, which excludes wasm32. The README also claimed the wire types redact their Debug output while only the runtime crate tested it. A test in this crate now builds one value of every type it applies redacted_debug! to, from canary strings, and asserts each Debug rendering carries the placeholder and none of the canaries. Breaking the macro to print a field makes it fail, so the claim is now guarded where it is made. The fixture published-key bound and the verifier trusted-key bound are both 33 with nothing tying them together. MAX_TRUSTED_KEYS is now pub(crate) and the fixtures assert equality at compile time, so a fixture can never build a key set the verifier would refuse without someone deciding to let the two numbers diverge. The assertion lives in the test-only fixtures module, so it is evaluated in test builds. MAX_PUBLISHED_KEYS stays as its own constant because it mirrors the runtime signer, which is a separate story from what the verifier accepts. The runtime's contracts module re-exported SCHEMA_DIALECT, EVIDENCE_SCHEMA_ID, REQUEST_NONCE_PATTERN, evidence_schema, and ContractValidationError as pub(crate), but nothing outside that module reads them. They are now a plain private use. This supersedes the corresponding line of the re-export notes in "fix(evidence): tighten verifier fixture fidelity and guard portability": those five names are private to the module again, not crate-visible. The #[cfg(test)] pub(crate) use of evidence_contract_accepts stays, because runtime_tests needs it. Security review notes: - No verification, signing, evaluation, authentication, authorization, audit, or data-minimization behavior changes. The narrowed use, the pub(crate) constant, and the compile-time assertion are visibility and compile-time concerns, and the added test asserts existing behavior. - The corrected portability wording is a security-relevant accuracy fix rather than a code change: a reader planning a wasm or toolchain-less client would otherwise have trusted a claim the dependency graph does not support. - The added test builds values from synthetic canary strings only. No key material, credential, or real identifier is introduced. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-verifier/README.md | 7 +- .../src/fixtures.rs | 5 + crates/registry-evidence-verifier/src/lib.rs | 9 +- .../registry-evidence-verifier/src/model.rs | 97 +++++++++++++++++++ .../src/verifier.rs | 2 +- crates/registry-evidence/src/contracts.rs | 2 +- 6 files changed, 116 insertions(+), 6 deletions(-) diff --git a/crates/registry-evidence-verifier/README.md b/crates/registry-evidence-verifier/README.md index 74d8e729d..218211ea5 100644 --- a/crates/registry-evidence-verifier/README.md +++ b/crates/registry-evidence-verifier/README.md @@ -66,7 +66,12 @@ products/evidence/scripts/check-verifier-portability.sh The second command proves the normal dependency tree still carries no async runtime, HTTP stack, script engine, command line parser, or logging framework, -so client tooling can link this crate on any platform. +so client tooling links none of the service runtime. + +It does not make the crate target independent. The crypto stack reaches +`aws-lc-sys`, so a build needs a C toolchain and is limited to the targets +`aws-lc-sys` supports. Native Windows, macOS, and Linux builds work; `wasm32` +and cross-compiles without a toolchain for the target do not. ## License diff --git a/crates/registry-evidence-verifier/src/fixtures.rs b/crates/registry-evidence-verifier/src/fixtures.rs index 13c783d26..0b8e1f15e 100644 --- a/crates/registry-evidence-verifier/src/fixtures.rs +++ b/crates/registry-evidence-verifier/src/fixtures.rs @@ -34,6 +34,11 @@ pub const OFFLINE_EVALUATION_REQUEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAA const MAX_PUBLISHED_KEYS: usize = 33; +// Two invariants meet at this number: the fixtures mirror the runtime signer's +// published-key bound, and a fixture must never build a key set the verifier +// would refuse. Divergence has to be a deliberate decision, not a drift. +const _: () = assert!(MAX_PUBLISHED_KEYS == crate::verifier::MAX_TRUSTED_KEYS); + #[derive(Debug)] pub enum FixtureSigningError { Algorithm, diff --git a/crates/registry-evidence-verifier/src/lib.rs b/crates/registry-evidence-verifier/src/lib.rs index fe7b99276..0a156286c 100644 --- a/crates/registry-evidence-verifier/src/lib.rs +++ b/crates/registry-evidence-verifier/src/lib.rs @@ -2,9 +2,12 @@ //! //! This crate owns the response wire formats, the Evidence payload contract, //! and the strict relying-party verifier. It carries no server, no source -//! access, no configuration loading, and no platform-specific requirement, so -//! any consumer that can parse JSON can verify a stored response with the same -//! rules the runtime applies. +//! access, no configuration loading, and no service-runtime dependency, so a +//! client can verify a stored response with the same rules the runtime applies. +//! +//! Portable here means free of the service runtime, not target independent. +//! The crypto stack reaches `aws-lc-sys`, so a build needs a C toolchain and is +//! limited to the targets `aws-lc-sys` supports, which excludes `wasm32`. pub mod contracts; pub mod model; diff --git a/crates/registry-evidence-verifier/src/model.rs b/crates/registry-evidence-verifier/src/model.rs index c785db24c..f97814940 100644 --- a/crates/registry-evidence-verifier/src/model.rs +++ b/crates/registry-evidence-verifier/src/model.rs @@ -297,3 +297,100 @@ redacted_debug!( FlattenedJws, UnsignedEvidenceEnvelope, ); + +#[cfg(test)] +mod tests { + use super::*; + + /// Every wire type this crate declares takes its `Debug` from + /// `redacted_debug!`, so a verified payload cannot leak disclosed material + /// into a log line, a panic message, or a snapshot. Each value below is + /// built from canary strings that must not survive formatting. + #[test] + fn debug_surfaces_redact_every_wire_type_this_crate_owns() { + let evidence = Evidence { + schema: "protected-schema-canary".to_owned(), + assurance_profile: AssuranceProfile::EvidenceGrade, + request_nonce: "protected-request-nonce-canary".to_owned(), + id: "protected-evidence-id-canary".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "protected-requirement-canary".to_owned(), + is_conformant_to: "protected-evidence-type-canary".to_owned(), + issued_by: "protected-issuer-canary".to_owned(), + provided_by: "protected-provider-canary".to_owned(), + issued_at: "2026-08-05T00:00:00Z".to_owned(), + observed_at: "2026-08-05T00:00:00Z".to_owned(), + valid_until: "2026-08-06T00:00:00Z".to_owned(), + purpose: "protected-purpose-canary".to_owned(), + audience: "protected-audience-canary".to_owned(), + configuration_revision: "protected-revision-canary".to_owned(), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: "protected-binding-canary".to_owned(), + }], + supported_values: vec![SupportedValue { + provides_value_for: "protected-concept-canary".to_owned(), + value: PublicValue::String("protected-supported-value-canary".to_owned()), + }], + }; + let holder_key = HolderPublicKey { + kty: "OKP".to_owned(), + crv: "Ed25519".to_owned(), + x: "protected-holder-coordinate-canary".to_owned(), + alg: Some("EdDSA".to_owned()), + kid: Some("protected-holder-key-id-canary".to_owned()), + }; + let bucket = BucketValue { + form: BucketForm::DateBucket, + scheme: "protected-bucket-scheme-canary".to_owned(), + bucket: "protected-bucket-canary".to_owned(), + }; + let entity_reference = EntityReferenceValue { + form: EntityReferenceForm::AudienceScopedEntityReference, + reference: "protected-reference-canary".to_owned(), + }; + let structured = StructuredValue { + form: StructuredValueForm::ReviewedStructuredValue, + schema: "protected-structured-schema-canary".to_owned(), + fields: BTreeMap::from([( + "protected-field-name-canary".to_owned(), + Value::String("protected-field-value-canary".to_owned()), + )]), + }; + let signed = FlattenedJws { + protected: "protected-header-canary".to_owned(), + payload: "protected-payload-canary".to_owned(), + signature: "protected-signature-canary".to_owned(), + }; + let unsigned_envelope = UnsignedEvidenceEnvelope { + schema: crate::EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: UnsignedIntegrityProtection::None, + warning: UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence: evidence.clone(), + }; + + for diagnostic in [ + format!("{holder_key:?}"), + format!("{evidence:?}"), + format!("{:?}", evidence.subjects[0]), + format!("{:?}", evidence.supported_values[0]), + format!( + "{:?}", + PublicValue::String("protected-supported-value-canary".to_owned()) + ), + format!( + "{:?}", + ScalarOrEntityReference::String("protected-list-item-canary".to_owned()) + ), + format!("{bucket:?}"), + format!("{entity_reference:?}"), + format!("{structured:?}"), + format!("{signed:?}"), + format!("{unsigned_envelope:?}"), + ] { + assert!(diagnostic.contains(""), "{diagnostic}"); + assert!(!diagnostic.contains("canary"), "{diagnostic}"); + } + } +} diff --git a/crates/registry-evidence-verifier/src/verifier.rs b/crates/registry-evidence-verifier/src/verifier.rs index d724ef8dd..d599154d5 100644 --- a/crates/registry-evidence-verifier/src/verifier.rs +++ b/crates/registry-evidence-verifier/src/verifier.rs @@ -25,7 +25,7 @@ use crate::{ const MAX_JWS_BYTES: usize = 256 * 1024; const MAX_PROTECTED_BYTES: usize = 8 * 1024; const MAX_PAYLOAD_BYTES: usize = 128 * 1024; -const MAX_TRUSTED_KEYS: usize = 33; +pub(crate) const MAX_TRUSTED_KEYS: usize = 33; /// One disclosure per Supported Value, bounded well above the largest /// requirement a Version 1 bundle can declare. const MAX_DISCLOSURES: usize = 64; diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index 9fd57210c..dfbf3efab 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -13,7 +13,7 @@ use std::{ }; use jsonschema::{Draft, JSONSchema}; -pub(crate) use registry_evidence_verifier::contracts::{ +use registry_evidence_verifier::contracts::{ evidence_schema, ContractValidationError, EVIDENCE_SCHEMA_ID, REQUEST_NONCE_PATTERN, SCHEMA_DIALECT, }; From 51890018ee5881752686d498c91f0c9ab2af339a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 02:09:20 +0700 Subject: [PATCH 07/67] chore: build jsonschema without default features jsonschema 0.18's default features are resolve-http, resolve-file, and cli, which pull reqwest and clap into every consumer. Nothing in this workspace resolves a remote or file $ref: the two schemas here that carry an external $ref supply the referenced document in memory with JSONSchema::options().with_document, in the registryctl project report contract test and in the Evidence contracts module. Nothing invokes the bundled CLI either. The workspace entry now turns defaults off and keeps only draft202012, which is the dialect every consumer compiles against. The workspace entry alone was not enough. registry-relay carried two independent local 0.18 pins, one optional dependency behind spdci-api-standards and one dev-dependency, both with defaults on, and a workspace build unifies features across members. Both now inherit the workspace entry, so the built graph really loses reqwest and clap rather than just the verifier's own view of it. registry-evidence-verifier's local pin becomes an inherit too, and its comment about default features moves to the workspace entry where the decision now lives. cargo tree -i clap --locked and cargo tree -i reqwest --locked no longer reach either package through jsonschema from any crate. clap now arrives only through criterion in dev builds and through the four crates with a command line interface, and reqwest only through the OIDC and source HTTP paths that ask for it. Cargo.lock shrinks accordingly. Security review notes: - Relay's SP DCI adapter compiles an adopter-supplied response schema from standards.spdci response_schema_path at startup. With resolve-http and resolve-file off, a schema containing an external $ref now fails configuration validation with spdci.config.schema_compile_failed instead of fetching the reference. No schema in this tree does that, and refusing to make a network request while validating configuration is the safer default, but it is an adopter-facing change on a deployment path and the release notes should say so. The feature is opt-in and off in relay's default build; it is enabled by registryctl's relay dependency and by the demo image. - No change to authentication, authorization, assertion evaluation, signing, audit, or data minimization. Schema validation semantics for schemas without external references are identical: draft202012 is retained and the resolve features only govern how an external $ref is fetched. - Removing an HTTP client and an argument parser from the dependency graph narrows the attack surface of every binary that compiles a JSON schema. Signed-off-by: Jeremi Joslin --- Cargo.lock | 4 ---- Cargo.toml | 5 ++++- crates/registry-evidence-verifier/Cargo.toml | 4 +--- crates/registry-relay/Cargo.toml | 4 ++-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad6b2c59b..29dff098a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3889,7 +3889,6 @@ dependencies = [ "anyhow", "base64", "bytecount", - "clap", "fancy-regex 0.13.0", "fraction", "getrandom 0.2.17", @@ -3901,7 +3900,6 @@ dependencies = [ "parking_lot", "percent-encoding", "regex", - "reqwest 0.12.28", "serde", "serde_json", "time", @@ -5909,9 +5907,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index 28f18ca7f..e072b075a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,7 +100,10 @@ hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } inquire = { version = "0.9.4" } insta = { version = "1", features = ["json"] } ipnet = { version = "2" } -jsonschema = { version = "0.18", features = ["draft202012"] } +# Default features pull an HTTP client for remote $ref resolution and a command +# line parser for the bundled CLI. Every consumer here compiles schemas it +# already holds in memory, so neither belongs in the dependency graph. +jsonschema = { version = "0.18", default-features = false, features = ["draft202012"] } jsonwebtoken = { version = "10", default-features = false, features = ["aws_lc_rs"] } native-tls = { version = "0.2" } ogcapi-types = { version = "0.3.0", features = ["features"] } diff --git a/crates/registry-evidence-verifier/Cargo.toml b/crates/registry-evidence-verifier/Cargo.toml index 70f7da692..f87ceb805 100644 --- a/crates/registry-evidence-verifier/Cargo.toml +++ b/crates/registry-evidence-verifier/Cargo.toml @@ -14,9 +14,7 @@ workspace = true [dependencies] base64.workspace = true chrono.workspace = true -# The default features of this dependency pull an HTTP client and a command -# line parser, neither of which a portable verifier may carry. -jsonschema = { version = "0.18", default-features = false, features = ["draft202012"] } +jsonschema.workspace = true registry-platform-crypto.workspace = true registry-platform-sdjwt.workspace = true schemars.workspace = true diff --git a/crates/registry-relay/Cargo.toml b/crates/registry-relay/Cargo.toml index 86742a17a..2451b76e8 100644 --- a/crates/registry-relay/Cargo.toml +++ b/crates/registry-relay/Cargo.toml @@ -87,7 +87,7 @@ crosswalk-functions = { workspace = true, features = ["date", "redaction"] } # Crosswalk CEL support for canonical attribute release and optional mapping # adapters. crosswalk-core = { workspace = true, optional = true } -jsonschema = { version = "0.18", optional = true } +jsonschema = { workspace = true, optional = true } # Pure Rust YAML parser used instead of the previous unmaintained YAML stack. serde-saphyr = { version = "0.0.26" } humantime-serde = { version = "1" } @@ -158,7 +158,7 @@ insta = { version = "1", features = ["json"] } # don't multiply build-time copies. zip = { version = "8", default-features = false, features = ["deflate"] } # Validates payloads in standards-adapter tests. -jsonschema = { version = "0.18", features = ["draft202012"] } +jsonschema.workspace = true # Ed25519 key generation for tests so they can mint fresh keypairs and # convert them to/from JWK without shelling out to openssl. ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } From 57f12ebeab3e92e1f86e4c500afadab0224c1122 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 02:25:04 +0700 Subject: [PATCH 08/67] fix(evidence): strengthen redaction canary and portability prose The redaction test built UnsignedEvidenceEnvelope with its real schema constant, so the only strings it could leak were that constant, two enum discriminants, and a nested Evidence that redacts itself. Dropping the envelope from redacted_debug! would have left the test green. Its schema field now carries a canary, which makes the envelope's own redaction load-bearing: deriving Debug for the envelope and removing it from the macro list fails the test with the canary in the panic message. The portability prose named aws-lc-sys without saying how the crate reaches it. Both the crate documentation and the README now name the two edges, registry-platform-crypto's use of aws-lc-rs for RS256 key handling and verification and registry-platform-sdjwt's through jsonwebtoken, so a reader debugging a cross-build sees where the constraint enters without tracing the dependency graph. Commit bodies get squashed, so the prose is the durable home for this. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-verifier/README.md | 5 ++++- crates/registry-evidence-verifier/src/lib.rs | 5 ++++- crates/registry-evidence-verifier/src/model.rs | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/registry-evidence-verifier/README.md b/crates/registry-evidence-verifier/README.md index 218211ea5..08598088f 100644 --- a/crates/registry-evidence-verifier/README.md +++ b/crates/registry-evidence-verifier/README.md @@ -71,7 +71,10 @@ so client tooling links none of the service runtime. It does not make the crate target independent. The crypto stack reaches `aws-lc-sys`, so a build needs a C toolchain and is limited to the targets `aws-lc-sys` supports. Native Windows, macOS, and Linux builds work; `wasm32` -and cross-compiles without a toolchain for the target do not. +and cross-compiles without a toolchain for the target do not. Two edges lead +there: `registry-platform-crypto` uses `aws-lc-rs` for RS256 key handling and +verification, and `registry-platform-sdjwt` reaches the same library through +`jsonwebtoken`. ## License diff --git a/crates/registry-evidence-verifier/src/lib.rs b/crates/registry-evidence-verifier/src/lib.rs index 0a156286c..66fe3e663 100644 --- a/crates/registry-evidence-verifier/src/lib.rs +++ b/crates/registry-evidence-verifier/src/lib.rs @@ -7,7 +7,10 @@ //! //! Portable here means free of the service runtime, not target independent. //! The crypto stack reaches `aws-lc-sys`, so a build needs a C toolchain and is -//! limited to the targets `aws-lc-sys` supports, which excludes `wasm32`. +//! limited to the targets `aws-lc-sys` supports, which excludes `wasm32`. Two +//! edges lead there: `registry-platform-crypto` uses `aws-lc-rs` for RS256 key +//! handling and verification, and `registry-platform-sdjwt` reaches the same +//! library through `jsonwebtoken`. pub mod contracts; pub mod model; diff --git a/crates/registry-evidence-verifier/src/model.rs b/crates/registry-evidence-verifier/src/model.rs index f97814940..947429b28 100644 --- a/crates/registry-evidence-verifier/src/model.rs +++ b/crates/registry-evidence-verifier/src/model.rs @@ -362,8 +362,11 @@ mod tests { payload: "protected-payload-canary".to_owned(), signature: "protected-signature-canary".to_owned(), }; + // The envelope's other fields are enum discriminants and a nested + // `Evidence` that redacts itself, so only a canary here makes the + // envelope's own redaction load-bearing. let unsigned_envelope = UnsignedEvidenceEnvelope { - schema: crate::EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + schema: "protected-envelope-schema-canary".to_owned(), envelope_type: UnsignedEnvelopeType::UnsignedEvidenceEnvelope, integrity_protection: UnsignedIntegrityProtection::None, warning: UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, From 2f7723b185f5a609d91841c0a8e6228ff3cfeb21 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 02:27:21 +0700 Subject: [PATCH 09/67] docs(relay): document the spdci schema reference constraint The SP DCI adapter compiles an adopter-supplied response schema at startup with no remote or file reference resolution, so an external $ref fails configuration validation instead of being fetched. The SP DCI section documented response_mapping_path's feature requirement but said nothing about this, leaving an adopter to discover it from an error code. The guide now states the constraint beside the mapping-path note: the schema must be self-contained, internal references resolve normally, and an external http(s) or file target fails with spdci.config.schema_compile_failed. It also states the reason, that validating configuration never makes a network request. Signed-off-by: Jeremi Joslin --- crates/registry-relay/docs/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index c958f0308..ef21d328e 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -1072,6 +1072,8 @@ For generic sync search, `identifiers` maps DCI `idtype-value` query types to en For `/dci/{registry}/registry/sync/disabled`, the caller needs the entity `evidence_verification_scope`. Generic search, details, and support need the entity `read_scope`. API-key authentication is still Registry Relay's normal auth layer. If a registry entry uses `response_mapping_path`, the binary must also be built with `--features standards-cel-mapping`; otherwise config validation fails with `spdci.config.mapping_feature_disabled`. +A `response_schema_path` schema must be self-contained. Internal `#/` references resolve normally, but an external `$ref` naming an `http(s)://` or `file://` target fails config validation with `spdci.config.schema_compile_failed`, because the schema compiler resolves no remote or file references and validating configuration never makes a network request. Inline the referenced definitions instead. + ## API keys ```yaml From 3615c9f3ef9d9fb6ecf112687017267dab10380c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 03:52:07 +0700 Subject: [PATCH 10/67] feat(evidence): add relying-party client SDK crate Adopter tooling beside the runtime, like registry-evidencectl: it requests a signed assertion over the public HTTP contract and links registry-evidence-verifier for every judgement about the answer. It re-implements no part of evaluation, signing, or verification, and it sits outside the frozen Version 1 runtime contract. The exchange is prepare, send, verify. prepare generates the nonce and closes the verification policy before any byte leaves the process, so a response is judged only against expectations that predate it. There is no retry at any layer: a second attempt is a second prepare with a fresh nonce. Security-sensitive surfaces that need review notes: - Policy construction. prepare builds the whole EvidenceVerificationPolicyDocument, including the evidence type, issuer, provider, configuration revision, assurance profile, expected outputs, lifetime bound, and clock skew. Anything omitted or widened here is an expectation the verifier will not enforce. - First-use subject acceptance. Subject bindings are keyed by a secret only the deployment holds, so a relying party cannot derive the binding for a subject it has never seen, and the verifier requires exact subject-set equality. SubjectExpectations::Pinned is the only setting under which a verified response proves the assertion is about the intended subject. AcceptFirstUse copies the response's claimed bindings into the policy and then runs the ordinary verifier; it adopts bindings only for exactly the requested roles, once each, and adopts nothing otherwise so the verifier refuses. No verifier bypass was added. - Credential handling. Tokens live in a wiped buffer, are marked sensitive on the outbound header, and never reach an error, a Debug rendering, or a log line. Response bytes and header values are withheld from diagnostics; a failure carries only the deployment's operation identifier, accepted after a bounded alphanumeric check. - Response bounds. Every body is read under a caller-configured byte bound before parsing, redirects are disabled, rustls is selected explicitly, and a pinned certificate bundle disables the platform trust store. The integration suite drives the real evidence runtime in-process over loopback, so discovery, the request contract, the problem contract, and verification are proven against the runtime rather than a stub. The crate is added to the evidence CI shard and to the source-neutrality scan. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 1 + AGENTS.md | 1 + Cargo.lock | 24 + Cargo.toml | 2 + crates/registry-evidence-client/Cargo.toml | 34 + crates/registry-evidence-client/README.md | 91 ++ crates/registry-evidence-client/src/client.rs | 787 ++++++++++++++ crates/registry-evidence-client/src/config.rs | 254 +++++ .../src/definitions.rs | 393 +++++++ crates/registry-evidence-client/src/error.rs | 141 +++ .../registry-evidence-client/src/fixtures.rs | 203 ++++ crates/registry-evidence-client/src/lib.rs | 106 ++ crates/registry-evidence-client/src/nonce.rs | 135 +++ .../registry-evidence-client/src/prepare.rs | 694 +++++++++++++ .../registry-evidence-client/src/problem.rs | 288 ++++++ .../registry-evidence-client/src/request.rs | 156 +++ crates/registry-evidence-client/src/token.rs | 150 +++ .../tests/against_a_real_deployment.rs | 974 ++++++++++++++++++ products/evidence/AGENTS.md | 7 + .../scripts/check-source-neutrality.sh | 2 + 20 files changed, 4443 insertions(+) create mode 100644 crates/registry-evidence-client/Cargo.toml create mode 100644 crates/registry-evidence-client/README.md create mode 100644 crates/registry-evidence-client/src/client.rs create mode 100644 crates/registry-evidence-client/src/config.rs create mode 100644 crates/registry-evidence-client/src/definitions.rs create mode 100644 crates/registry-evidence-client/src/error.rs create mode 100644 crates/registry-evidence-client/src/fixtures.rs create mode 100644 crates/registry-evidence-client/src/lib.rs create mode 100644 crates/registry-evidence-client/src/nonce.rs create mode 100644 crates/registry-evidence-client/src/prepare.rs create mode 100644 crates/registry-evidence-client/src/problem.rs create mode 100644 crates/registry-evidence-client/src/request.rs create mode 100644 crates/registry-evidence-client/src/token.rs create mode 100644 crates/registry-evidence-client/tests/against_a_real_deployment.rs diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 207042ec8..fe7d98fa1 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -33,6 +33,7 @@ "relay": ("registry-relay",), "evidence": ( "registry-evidence", + "registry-evidence-client", "registry-evidence-verifier", "registry-evidencectl", ), diff --git a/AGENTS.md b/AGENTS.md index 098fb7b90..e90b6bc0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Evidence's authenticator; Evidence does not depend on Mint. | `crates/registry-relay` | Protected read APIs (Relay) | | `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | | `crates/registry-evidence-verifier` | Portable Evidence response verification, shared by the runtime and client tooling | +| `crates/registry-evidence-client` | Evidence relying-party SDK: requests assertions and verifies them via `registry-evidence-verifier` | | `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, incomplete OpenAPI authoring workspaces, fixture runs for complete projects | | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | diff --git a/Cargo.lock b/Cargo.lock index 29dff098a..4e59ff8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5456,6 +5456,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "registry-evidence-client" +version = "0.17.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "ed25519-dalek", + "getrandom 0.4.3", + "registry-evidence", + "registry-evidence-verifier", + "registry-platform-crypto", + "registry-platform-httputil", + "reqwest 0.12.28", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "url", + "wiremock", + "zeroize", +] + [[package]] name = "registry-evidence-verifier" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index e072b075a..d6f2ac0d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/registry-config-report", "crates/registry-evidence", + "crates/registry-evidence-client", "crates/registry-evidence-verifier", "crates/registry-evidencectl", "crates/registry-platform-audit", @@ -43,6 +44,7 @@ unsafe_code = "forbid" [workspace.dependencies] registry-config-report = { path = "crates/registry-config-report", version = "0.17.0" } registry-evidence = { path = "crates/registry-evidence", version = "0.17.0" } +registry-evidence-client = { path = "crates/registry-evidence-client", version = "0.17.0" } registry-evidence-verifier = { path = "crates/registry-evidence-verifier", version = "0.17.0" } registry-language-server = { path = "crates/registry-language-server", version = "0.17.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.17.0" } diff --git a/crates/registry-evidence-client/Cargo.toml b/crates/registry-evidence-client/Cargo.toml new file mode 100644 index 000000000..7604c8238 --- /dev/null +++ b/crates/registry-evidence-client/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "registry-evidence-client" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Relying-party client for requesting and verifying signed Evidence responses." +readme = "README.md" +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +base64.workspace = true +chrono.workspace = true +getrandom.workspace = true +registry-evidence-verifier.workspace = true +registry-platform-httputil.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +url.workspace = true +zeroize.workspace = true + +[dev-dependencies] +ed25519-dalek.workspace = true +registry-evidence.workspace = true +registry-platform-crypto.workspace = true +tempfile.workspace = true +tokio.workspace = true +wiremock.workspace = true diff --git a/crates/registry-evidence-client/README.md b/crates/registry-evidence-client/README.md new file mode 100644 index 000000000..29afb8fda --- /dev/null +++ b/crates/registry-evidence-client/README.md @@ -0,0 +1,91 @@ +# registry-evidence-client + +Relying-party client for requesting and verifying signed Evidence Version 1 +responses. + +This is adopter tooling beside the Evidence runtime, like `registryctl` is for +the rest of the stack. It sits outside the frozen Version 1 runtime contract and +it re-implements no part of evaluation, signing, or verification: every +judgement about a response is made by `registry-evidence-verifier`. + +## What It Provides + +- `EvidenceClient::prepare`: generates the request nonce and closes the + verification policy before any byte leaves the process. +- `EvidenceClient::send` and `EvidenceClient::verify`, or + `EvidenceClient::request_and_verify` for both at once. `send` returns the exact + response bytes so a relying party can retain what it verified. +- `EvidenceClient::discover`: the requester-scoped definitions document, for + authoring a relying procedure against the shapes a deployment will accept. +- `EvidenceClient::fetch_jwks`: the deployment's published key set, for an + out-of-band pinning workflow only. +- `TokenProvider` and `StaticToken` for the bearer credential the deployment's + resource-server authentication expects. + +## Typical Use + +```rust,no_run +use std::sync::Arc; + +use registry_evidence_client::{ + EvidenceClient, EvidenceClientConfig, EvidenceClientError, PreparedEvidenceRequest, + StaticToken, VerifiedEvidence, +}; + +/// `trusted_jwks` is the key set the integrator reviewed and pinned out of band. +/// The prepared request carries the nonce and the closed policy that will judge +/// the answer. +async fn accept( + base_url: url::Url, + access_token: &str, + trusted_jwks: registry_evidence_client::JwksDocument, + prepared: &PreparedEvidenceRequest, +) -> Result { + let client = EvidenceClient::new(EvidenceClientConfig::new( + base_url, + Arc::new(StaticToken::new(access_token)?), + trusted_jwks, + ))?; + client.request_and_verify(prepared).await +} +``` + +## Security Notes + +- The published key set is discovery, not a trust anchor. Verification always + uses the key set pinned at construction. Nothing here fetches keys at + verification time, because a key set taken from the same origin as the + response it would verify establishes nothing about that response. +- One prepared request is one exchange. Neither this crate nor its HTTP client + retries anything: a second attempt is a second `prepare` with a fresh nonce, + because a policy accepts exactly the answer to the request it was closed for. +- Subject bindings are keyed values the deployment computes with a secret only it + holds, so a relying party cannot derive the binding for a subject it has never + seen. `SubjectExpectations::Pinned` is the only setting under which a verified + response proves the assertion is about the subject the relying party meant. + `SubjectExpectations::AcceptFirstUse` accepts the deployment's own answer to + the identity question once, enforces every other expectation, and exposes the + accepted bindings so the caller persists them and pins them from then on. It + adopts bindings only for exactly the roles the request asked about, once each, + so a response that renames, adds, or drops a role is refused. +- Credentials are held in a buffer that is wiped on drop, marked sensitive on the + outbound header, and never placed in an error, a `Debug` rendering, or a log + line. Response bytes and header values are withheld from diagnostics too; a + failure carries the deployment's operation identifier for support correlation. +- Every response is read under a caller-configured byte bound before it is + parsed. + +## Testing + +```sh +cargo test -p registry-evidence-client +``` + +The integration suite starts a real Evidence deployment over loopback HTTP and +drives the whole exchange through it, so discovery, the request contract, the +problem contract, and verification are proven against the runtime rather than +against a stub. + +## License + +Apache-2.0. diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs new file mode 100644 index 000000000..d446bdc86 --- /dev/null +++ b/crates/registry-evidence-client/src/client.rs @@ -0,0 +1,787 @@ +//! The HTTP client: one request, one offline verification. +//! +//! Every exchange here is bounded and unretried. The only judgement the client +//! makes about a response is the one the portable verifier makes for it, against +//! the policy the caller closed before the request existed. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use registry_evidence_verifier::{ + model::{Evidence, FlattenedJws, JwksDocument, SubjectBinding}, + verifier::{verify_flattened_jws, ExpectedSubjectDocument}, + EVIDENCE_JWS_MEDIA_TYPE, +}; +use registry_platform_httputil::read_bounded; +use reqwest::{ + header::{HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}, + Method, StatusCode, +}; +use url::Url; +use zeroize::Zeroizing; + +use crate::{ + config::EvidenceClientConfig, + definitions::EvidenceDefinitionsDocument, + error::{EvidenceClientError, TransportKind}, + prepare::{EvidenceRequestSpec, PreparedEvidenceRequest, SubjectExpectations}, + problem::{essence, map_problem}, + request::EvidenceRequestBody, +}; + +/// Path of the Evidence request endpoint. +const EVIDENCE_PATH: &str = "v1/evidence"; +/// Path of the requester-scoped discovery endpoint. +const DEFINITIONS_PATH: &str = "v1/evidence-definitions"; +/// Path of the published verification key set. +const JWKS_PATH: &str = ".well-known/evidence/jwks.json"; + +const JSON_MEDIA_TYPE: &str = "application/json"; +const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; + +/// The opaque per-request identifier the deployment returns. +const CORRELATION_HEADER: &str = "x-request-id"; + +/// A relying party's connection to one Evidence deployment. +#[derive(Debug)] +pub struct EvidenceClient { + config: EvidenceClientConfig, + http: reqwest::Client, +} + +/// A signed response, read but not yet judged. +/// +/// It exists so a relying party can retain the exact bytes it verified. Nothing +/// in it has been trusted yet. +#[derive(Clone)] +pub struct RawEvidenceResponse { + body: Vec, + operation: Option, +} + +impl RawEvidenceResponse { + /// The signed response bytes, exactly as received. + #[must_use] + pub fn body(&self) -> &[u8] { + &self.body + } + + /// The deployment's opaque identifier for this exchange, for support + /// correlation. + #[must_use] + pub fn operation(&self) -> Option<&str> { + self.operation.as_deref() + } +} + +impl std::fmt::Debug for RawEvidenceResponse { + /// The body is unverified, potentially subject-identifying material, so only + /// its length and the correlation identifier are rendered. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RawEvidenceResponse") + .field("body_bytes", &self.body.len()) + .field("operation", &self.operation) + .finish_non_exhaustive() + } +} + +/// A response that satisfied every expectation. +#[derive(Debug, Clone)] +pub struct VerifiedEvidence { + evidence: Evidence, + operation: Option, +} + +impl VerifiedEvidence { + /// The verified payload. + #[must_use] + pub fn evidence(&self) -> &Evidence { + &self.evidence + } + + /// The deployment's opaque identifier for the exchange that produced this + /// payload. + #[must_use] + pub fn operation(&self) -> Option<&str> { + self.operation.as_deref() + } + + /// The role-bound subject bindings this payload carries, as pinned + /// expectations for later requests. + /// + /// Persist these after a first-use acceptance and pass them as + /// [`SubjectExpectations::Pinned`] from then on. Once pinned, a response + /// about a different subject fails verification instead of being accepted. + #[must_use] + pub fn pinned_subject_expectations(&self) -> Vec { + self.evidence + .subjects + .iter() + .map(|subject| ExpectedSubjectDocument { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect() + } +} + +impl EvidenceClient { + /// Build a client for one deployment. + pub fn new(config: EvidenceClientConfig) -> Result { + config.validate()?; + let http = build_client(&config)?; + Ok(Self { config, http }) + } + + #[must_use] + pub fn config(&self) -> &EvidenceClientConfig { + &self.config + } + + /// Close the expectations for one request and generate its nonce. + /// + /// No I/O happens here. The returned request is good for exactly one + /// exchange. + pub fn prepare( + &self, + spec: EvidenceRequestSpec, + ) -> Result { + PreparedEvidenceRequest::new(spec) + } + + /// Read the request shapes this requester is entitled to send. + /// + /// Discovery is authoring input, not a trust anchor. It tells a relying + /// party what it may ask for; it never supplies verification expectations + /// for a request already in flight. + pub async fn discover(&self) -> Result { + let url = self.endpoint(DEFINITIONS_PATH)?; + let request = self + .http + .request(Method::GET, url) + .header(ACCEPT, JSON_MEDIA_TYPE); + let response = self.exchange(request, true).await?; + let body = self.expect_success(response, JSON_MEDIA_TYPE).await?; + serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { + status: StatusCode::OK.as_u16(), + code: None, + operation: body.operation, + }) + } + + /// Read the deployment's published verification key set. + /// + /// This is for an out-of-band pinning workflow: fetch once, review the keys + /// against what the deployment operator published elsewhere, and configure + /// the reviewed set as the client's trusted key set. Verification never + /// calls this. A key set fetched from the same origin as the response it + /// would verify establishes nothing. + pub async fn fetch_jwks(&self) -> Result { + let url = self.endpoint(JWKS_PATH)?; + let request = self + .http + .request(Method::GET, url) + .header(ACCEPT, JWKS_MEDIA_TYPE); + let response = self.exchange(request, false).await?; + let body = self.expect_success(response, JWKS_MEDIA_TYPE).await?; + serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { + status: StatusCode::OK.as_u16(), + code: None, + operation: body.operation, + }) + } + + /// Send one prepared request and read the signed response. + /// + /// There is no retry, at this layer or below it. A nonce identifies exactly + /// one request, and a policy accepts exactly the answer to that request, so + /// a second attempt has to be a second [`EvidenceClient::prepare`] with a + /// fresh nonce. Retrying the same bytes would let a stale answer satisfy a + /// policy that was closed for a different exchange. + pub async fn send( + &self, + prepared: &PreparedEvidenceRequest, + ) -> Result { + let url = self.endpoint(EVIDENCE_PATH)?; + let body = serialize_request(prepared.body())?; + let request = self + .http + .request(Method::POST, url) + .header(ACCEPT, EVIDENCE_JWS_MEDIA_TYPE) + .header(CONTENT_TYPE, JSON_MEDIA_TYPE) + .body(body); + let response = self.exchange(request, true).await?; + self.expect_success(response, EVIDENCE_JWS_MEDIA_TYPE).await + } + + /// Verify a signed response against the policy its request closed. + /// + /// The trusted key set is the one pinned at construction, always. + pub fn verify( + &self, + prepared: &PreparedEvidenceRequest, + response: &RawEvidenceResponse, + ) -> Result { + self.verify_at(prepared, response, Utc::now()) + } + + /// Request evidence and verify it, in one step. + pub async fn request_and_verify( + &self, + prepared: &PreparedEvidenceRequest, + ) -> Result { + let response = self.send(prepared).await?; + self.verify(prepared, &response) + } + + /// Verification at an explicit instant, so a test can pin the clock. + pub(crate) fn verify_at( + &self, + prepared: &PreparedEvidenceRequest, + response: &RawEvidenceResponse, + now: DateTime, + ) -> Result { + let policy_document = match prepared.subject_expectations() { + SubjectExpectations::Pinned(_) => prepared.policy_document().clone(), + // Adopt the response's own role-bound bindings as expectations, then + // let the ordinary verifier apply the whole policy. Nothing else is + // taken from the response, and the subject question is deliberately + // deferred to the caller, which persists these bindings and pins + // them next time. + SubjectExpectations::AcceptFirstUse => { + prepared.policy_with_subjects(untrusted_subject_bindings(&response.body)) + } + }; + let policy = policy_document.into_policy(now); + let evidence = verify_flattened_jws(&response.body, &self.config.trusted_jwks, &policy) + .map_err(EvidenceClientError::Verification)?; + Ok(VerifiedEvidence { + evidence, + operation: response.operation.clone(), + }) + } + + /// Resolve one endpoint under the configured base URL. + fn endpoint(&self, path: &str) -> Result { + // `join` on a base whose path lacks a trailing separator would discard + // the last segment, so the deployment prefix is preserved explicitly. + let mut url = self.config.base_url.clone(); + { + let mut segments = url.path_segments_mut().map_err(|()| { + EvidenceClientError::configuration("the base URL must accept path segments") + })?; + segments.pop_if_empty(); + for segment in path.split('/') { + segments.push(segment); + } + } + Ok(url) + } + + /// Attach the credential when the endpoint requires one, and perform the + /// exchange. + async fn exchange( + &self, + request: reqwest::RequestBuilder, + authenticated: bool, + ) -> Result { + let request = if authenticated { + let token = self.config.token_provider.bearer_token().await?; + // The plaintext credential exists in one scrubbed buffer here. The + // header value reqwest owns afterwards cannot be zeroized, which is + // why it is marked sensitive below. + let mut credential = Zeroizing::new(String::with_capacity(7 + token.expose().len())); + credential.push_str("Bearer "); + credential.push_str(token.expose()); + let mut value = HeaderValue::from_str(&credential).map_err(|_| { + EvidenceClientError::configuration("the credential is not a usable header value") + })?; + // The credential must never reach a diagnostic, and reqwest honors + // this marking when it formats a request. + value.set_sensitive(true); + request.header(AUTHORIZATION, value) + } else { + request + }; + request.send().await.map_err(|error| { + let kind = if error.is_timeout() { + TransportKind::Timeout + } else if error.is_connect() { + // TLS negotiation failures arrive here too. Separating them + // would mean reading a transport error chain whose text this + // crate must not copy into a diagnostic. + TransportKind::Connect + } else { + TransportKind::Exchange + }; + EvidenceClientError::transport(kind) + }) + } + + /// Read a successful response of exactly one media type, or map the + /// deployment's answer onto a client failure. + async fn expect_success( + &self, + response: reqwest::Response, + expected_media_type: &str, + ) -> Result { + let status = response.status().as_u16(); + let operation = sanitized_correlation_id(&response); + let media_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let retry_after_seconds = response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()); + + let body = read_bounded(response, self.config.max_response_bytes) + .await + .map_err(|_| { + // Both an oversized body and a failed read collapse here: the + // bound is the caller's, and the underlying text is not + // something this crate copies into a diagnostic. + EvidenceClientError::transport(TransportKind::ResponseTooLarge) + })?; + + if !(200..300).contains(&status) { + return Err(map_problem( + status, + media_type.as_deref(), + &body, + retry_after_seconds, + )); + } + if status != StatusCode::OK.as_u16() + || media_type.as_deref().map(essence) != Some(expected_media_type.to_owned()) + { + return Err(EvidenceClientError::Protocol { + status, + code: None, + operation, + }); + } + Ok(RawEvidenceResponse { body, operation }) + } +} + +/// Build the outbound client. +fn build_client(config: &EvidenceClientConfig) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(config.request_timeout) + .connect_timeout(config.connect_timeout) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + // Select rustls explicitly. Cargo unifies reqwest's feature set across + // a whole build, so another crate enabling reqwest's native-tls feature + // must not silently change which TLS backend this client uses. + .use_rustls_tls() + // One prepared request is one exchange. A transport-level retry would + // resend a nonce the relying party's policy has already committed to + // and would duplicate an outbound call the caller did not ask for. + .retry(reqwest::retry::never()); + if let Some(user_agent) = &config.user_agent { + builder = builder.user_agent(user_agent.clone()); + } + if let Some(pem) = &config.trusted_root_certificates { + let certificates = reqwest::Certificate::from_pem_bundle(pem).map_err(|_| { + EvidenceClientError::configuration( + "the pinned certificate authority bundle is not readable PEM", + ) + })?; + if certificates.is_empty() { + return Err(EvidenceClientError::configuration( + "the pinned certificate authority bundle carries no certificate", + )); + } + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + // Trust exactly what the integrator pinned. Leaving the platform store + // enabled would mean any of its authorities could also vouch for the + // deployment, which is the opposite of pinning. + builder = builder.tls_built_in_root_certs(false); + } + builder.build().map_err(|_| { + EvidenceClientError::configuration("the outbound client options are not usable") + }) +} + +fn serialize_request(body: &EvidenceRequestBody) -> Result, EvidenceClientError> { + serde_json::to_vec(body) + .map_err(|_| EvidenceClientError::configuration("the request body cannot be serialized")) +} + +/// The correlation identifier, kept only when it is a bounded alphanumeric +/// value. A deployment cannot use this header to inject text into a relying +/// party's records. +fn sanitized_correlation_id(response: &reqwest::Response) -> Option { + let value = response + .headers() + .get(CORRELATION_HEADER)? + .to_str() + .ok()? + .trim(); + let acceptable = !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric()); + acceptable.then(|| value.to_owned()) +} + +/// Read the role-bound subject bindings out of a response that has not been +/// verified. +/// +/// This is a bounded structural read of untrusted bytes, using the same strict +/// payload type the verifier uses, for one purpose only: turning the response's +/// claimed subject set into stated expectations under first-use acceptance. It +/// authenticates nothing. When the bytes are unreadable it yields no subject at +/// all, so the verifier itself refuses the response. +fn untrusted_subject_bindings(body: &[u8]) -> Vec { + let Ok(jws) = serde_json::from_slice::(body) else { + return Vec::new(); + }; + let Ok(payload) = URL_SAFE_NO_PAD.decode(jws.payload.as_bytes()) else { + return Vec::new(); + }; + let Ok(evidence) = serde_json::from_slice::(&payload) else { + return Vec::new(); + }; + evidence + .subjects + .iter() + .map(|subject: &SubjectBinding| ExpectedSubjectDocument { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + fixtures::{ + signed_evidence, SignedEvidenceFixture, AUDIENCE, CONCEPT, CONFIGURATION_REVISION, + EVIDENCE_TYPE, ISSUED_BY, MAXIMUM_LIFETIME_SECONDS, PROVIDED_BY, PURPOSE, REQUIREMENT, + }, + prepare::{EvidenceRequestSpec, SubjectRequest}, + request::SelectorValue, + token::StaticToken, + }; + use registry_evidence_verifier::{ + verifier::{ + ExpectedFormDocument, ExpectedOutputDocument, ExpectedScalarFormDocument, + VerificationError, + }, + AssuranceProfile, + }; + use std::sync::Arc; + + fn client_for(base_url: &str, fixture: &SignedEvidenceFixture) -> EvidenceClient { + EvidenceClient::new(EvidenceClientConfig::new( + Url::parse(base_url).expect("the base URL parses"), + Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), + fixture.trusted_jwks.clone(), + )) + .expect("the client is configured") + } + + fn client(fixture: &SignedEvidenceFixture) -> EvidenceClient { + client_for("https://evidence.example.org/", fixture) + } + + fn spec(subject_expectations: SubjectExpectations) -> EvidenceRequestSpec { + EvidenceRequestSpec { + requirement: REQUIREMENT.to_owned(), + purpose: PURPOSE.to_owned(), + audience: AUDIENCE.to_owned(), + evidence_type: EVIDENCE_TYPE.to_owned(), + issued_by: ISSUED_BY.to_owned(), + provided_by: PROVIDED_BY.to_owned(), + configuration_revision: CONFIGURATION_REVISION.to_owned(), + expected_assurance_profile: AssuranceProfile::Local, + subjects: vec![SubjectRequest { + role: "subject".to_owned(), + selector_profile: "record-lookup-v1".to_owned(), + selector_values: Some(vec![( + "record_reference".to_owned(), + SelectorValue::from("synthetic-record-001"), + )]), + }], + expected_outputs: vec![ExpectedOutputDocument { + concept: CONCEPT.to_owned(), + form: ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean), + }], + maximum_assertion_lifetime_seconds: MAXIMUM_LIFETIME_SECONDS, + clock_skew_seconds: 60, + subject_expectations, + } + } + + fn raw(body: Vec) -> RawEvidenceResponse { + RawEvidenceResponse { + body, + operation: Some("01JZZZOPERATION".to_owned()), + } + } + + #[test] + fn every_endpoint_hangs_off_the_configured_base_url_including_its_prefix() { + let fixture = signed_evidence(); + for (base, evidence, definitions, jwks) in [ + ( + "https://evidence.example.org", + "https://evidence.example.org/v1/evidence", + "https://evidence.example.org/v1/evidence-definitions", + "https://evidence.example.org/.well-known/evidence/jwks.json", + ), + ( + "https://evidence.example.org/registry/", + "https://evidence.example.org/registry/v1/evidence", + "https://evidence.example.org/registry/v1/evidence-definitions", + "https://evidence.example.org/registry/.well-known/evidence/jwks.json", + ), + ( + "https://evidence.example.org/registry", + "https://evidence.example.org/registry/v1/evidence", + "https://evidence.example.org/registry/v1/evidence-definitions", + "https://evidence.example.org/registry/.well-known/evidence/jwks.json", + ), + ] { + let client = client_for(base, &fixture); + for (path, expected) in [ + (EVIDENCE_PATH, evidence), + (DEFINITIONS_PATH, definitions), + (JWKS_PATH, jwks), + ] { + assert_eq!( + client.endpoint(path).expect("the path resolves").as_str(), + expected + ); + } + } + } + + #[test] + fn a_pinned_subject_set_verifies_the_response_it_was_pinned_for() { + let fixture = signed_evidence(); + let client = client(&fixture); + let prepared = client + .prepare(spec(SubjectExpectations::Pinned(vec![ + ExpectedSubjectDocument { + role: "subject".to_owned(), + binding: fixture.subject_binding.clone(), + }, + ]))) + .expect("the specification is accepted"); + let response = raw(fixture.sign(prepared.request_nonce())); + + let verified = client + .verify_at(&prepared, &response, fixture.now) + .expect("the response verifies"); + assert_eq!(verified.operation(), Some("01JZZZOPERATION")); + assert_eq!(verified.evidence().request_nonce, prepared.request_nonce()); + assert_eq!( + serde_json::to_value(verified.pinned_subject_expectations()) + .expect("the expectations serialize"), + serde_json::json!([{"role": "subject", "binding": fixture.subject_binding}]) + ); + } + + /// The whole point of pinning: once the relying party holds the binding, a + /// response about someone else is a verification failure, not an answer. + #[test] + fn a_pinned_subject_set_refuses_a_response_about_another_subject() { + let fixture = signed_evidence(); + let client = client(&fixture); + let prepared = client + .prepare(spec(SubjectExpectations::Pinned(vec![ + ExpectedSubjectDocument { + role: "subject".to_owned(), + binding: fixture.subject_binding.clone(), + }, + ]))) + .expect("the specification is accepted"); + let other_subject = "urn:evidence:subject:v1_WlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlo"; + let response = + raw(fixture.sign_with_subject_binding(prepared.request_nonce(), other_subject)); + + assert_eq!( + client + .verify_at(&prepared, &response, fixture.now) + .expect_err("the response is refused"), + EvidenceClientError::Verification(VerificationError::Policy) + ); + } + + #[test] + fn first_use_acceptance_adopts_the_subject_set_and_exposes_it_for_pinning() { + let fixture = signed_evidence(); + let client = client(&fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + let response = raw(fixture.sign(prepared.request_nonce())); + + let verified = client + .verify_at(&prepared, &response, fixture.now) + .expect("the response verifies"); + let pinned = verified.pinned_subject_expectations(); + assert_eq!(pinned.len(), 1); + assert_eq!(pinned[0].binding, fixture.subject_binding); + + // The adopted bindings are exactly what a later pinned request needs. + let next = client + .prepare(spec(SubjectExpectations::Pinned(pinned))) + .expect("the specification is accepted"); + let next_response = raw(fixture.sign(next.request_nonce())); + assert!(client.verify_at(&next, &next_response, fixture.now).is_ok()); + } + + /// First-use acceptance defers the subject question and nothing else. + #[test] + fn first_use_acceptance_still_enforces_every_other_expectation() { + let fixture = signed_evidence(); + let client = client(&fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + + // An answer to a different request, so a different nonce. + let other = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + assert_eq!( + client + .verify_at( + &prepared, + &raw(fixture.sign(other.request_nonce())), + fixture.now + ) + .expect_err("the response is refused"), + EvidenceClientError::Verification(VerificationError::Policy) + ); + + // An answer signed by a key the relying party did not pin. + let untrusted = signed_evidence(); + assert_eq!( + client + .verify_at( + &prepared, + &raw(untrusted.sign(prepared.request_nonce())), + fixture.now + ) + .expect_err("the response is refused"), + EvidenceClientError::Verification(VerificationError::Key) + ); + + // An answer whose stated purpose is not the one asked for. + assert_eq!( + client + .verify_at( + &prepared, + &raw(fixture.sign_with_purpose(prepared.request_nonce(), "other-decision")), + fixture.now + ) + .expect_err("the response is refused"), + EvidenceClientError::Verification(VerificationError::Policy) + ); + + // An answer outside its own validity interval. + assert_eq!( + client + .verify_at( + &prepared, + &raw(fixture.sign(prepared.request_nonce())), + fixture.now + chrono::TimeDelta::try_days(2).expect("the offset is valid") + ) + .expect_err("the response is refused"), + EvidenceClientError::Verification(VerificationError::Time) + ); + } + + /// First-use acceptance defers which subject an assertion is about. It does + /// not defer which roles were asked about, so a response that renames a role, + /// adds one, or drops one is refused rather than adopted. + #[test] + fn first_use_acceptance_adopts_only_the_roles_the_request_asked_about() { + let fixture = signed_evidence(); + let client = client(&fixture); + let other = "urn:evidence:subject:v1_WlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlo"; + for (subjects, expected) in [ + // A role the request never named. + ( + serde_json::json!([{"role": "other-role", "binding": fixture.subject_binding}]), + VerificationError::Policy, + ), + // The requested role plus one the request never named. + ( + serde_json::json!([ + {"role": "subject", "binding": fixture.subject_binding}, + {"role": "other-role", "binding": other}, + ]), + VerificationError::Policy, + ), + // The requested role twice. + ( + serde_json::json!([ + {"role": "subject", "binding": fixture.subject_binding}, + {"role": "subject", "binding": other}, + ]), + VerificationError::Policy, + ), + // No subject at all. The payload contract requires one, so the + // verifier refuses this before any policy comparison. + (serde_json::json!([]), VerificationError::Payload), + ] { + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + let response = raw(fixture.sign_with_subjects(prepared.request_nonce(), subjects)); + assert_eq!( + client + .verify_at(&prepared, &response, fixture.now) + .expect_err("the response is refused"), + EvidenceClientError::Verification(expected) + ); + } + } + + /// Under first-use acceptance an unreadable response yields no adopted + /// subject, so the verifier refuses it instead of the client guessing. + #[test] + fn first_use_acceptance_refuses_a_response_it_cannot_read() { + let fixture = signed_evidence(); + let client = client(&fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + + for body in [ + b"not json".to_vec(), + br#"{"protected":"","payload":"","signature":""}"#.to_vec(), + br#"{"protected":"AA","payload":"!!!","signature":"AA"}"#.to_vec(), + ] { + assert!(untrusted_subject_bindings(&body).is_empty()); + assert!(client + .verify_at(&prepared, &raw(body), fixture.now) + .is_err()); + } + } + + #[test] + fn debug_output_never_carries_a_response_body_or_a_credential() { + let fixture = signed_evidence(); + let client = client(&fixture); + let rendered = format!("{client:?}"); + assert!(!rendered.contains("test-token"), "{rendered}"); + + let response = raw(b"a-signed-response-canary".to_vec()); + let rendered = format!("{response:?}"); + assert!(!rendered.contains("canary"), "{rendered}"); + assert!(rendered.contains("body_bytes"), "{rendered}"); + } +} diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs new file mode 100644 index 000000000..d992789ee --- /dev/null +++ b/crates/registry-evidence-client/src/config.rs @@ -0,0 +1,254 @@ +//! What a relying party must decide before it can talk to a deployment. +//! +//! The trusted key set is the load-bearing decision. It is pinned here, by the +//! integrator, out of band. The client never replaces it with keys a response +//! or a discovery document named. + +use std::{fmt, sync::Arc, time::Duration}; + +use registry_evidence_verifier::model::JwksDocument; +use registry_platform_httputil::DEFAULT_OUTBOUND_CONNECT_TIMEOUT; +use url::Url; +use zeroize::Zeroizing; + +use crate::{error::EvidenceClientError, token::TokenProvider}; + +/// Longest response body the client will read. +/// +/// The verifier refuses a signed response larger than 256 KiB, so a bigger +/// body could never verify and reading it would only waste the relying party's +/// memory. +pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 256 * 1024; + +/// Total time allowed for one exchange, including reading the response body. +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Time allowed for connection setup, including TLS negotiation. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = DEFAULT_OUTBOUND_CONNECT_TIMEOUT; + +/// Everything the client needs, decided before the first request. +pub struct EvidenceClientConfig { + pub(crate) base_url: Url, + pub(crate) token_provider: Arc, + pub(crate) trusted_jwks: JwksDocument, + pub(crate) request_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) user_agent: Option, + pub(crate) trusted_root_certificates: Option>>, + pub(crate) max_response_bytes: u64, +} + +impl EvidenceClientConfig { + /// Configure a client against one deployment. + /// + /// `trusted_jwks` is the key set the relying party pinned out of band. It + /// is the only source of verification keys. Fetching the deployment's + /// published key set at verification time would make the response's own + /// origin the authority for trusting it, which proves nothing. + #[must_use] + pub fn new( + base_url: Url, + token_provider: Arc, + trusted_jwks: JwksDocument, + ) -> Self { + Self { + base_url, + token_provider, + trusted_jwks, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + user_agent: None, + trusted_root_certificates: None, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + } + } + + #[must_use] + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + #[must_use] + pub fn with_connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = timeout; + self + } + + #[must_use] + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Trust exactly these PEM-encoded certificate authorities for the + /// deployment's TLS certificate, instead of the platform's own store. + #[must_use] + pub fn with_trusted_root_certificates(mut self, pem_bundle: impl Into>) -> Self { + self.trusted_root_certificates = Some(Zeroizing::new(pem_bundle.into())); + self + } + + #[must_use] + pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { + self.max_response_bytes = max_response_bytes; + self + } + + #[must_use] + pub fn base_url(&self) -> &Url { + &self.base_url + } + + /// The pinned key set every verification uses. + #[must_use] + pub fn trusted_jwks(&self) -> &JwksDocument { + &self.trusted_jwks + } + + #[must_use] + pub fn max_response_bytes(&self) -> u64 { + self.max_response_bytes + } + + /// Refuse a configuration that cannot carry a credential safely or that + /// could never produce a readable response. + pub(crate) fn validate(&self) -> Result<(), EvidenceClientError> { + if !self.base_url.username().is_empty() + || self.base_url.password().is_some() + || self.base_url.query().is_some() + || self.base_url.fragment().is_some() + { + return Err(EvidenceClientError::configuration( + "the base URL must carry no credentials, query, or fragment", + )); + } + // A bearer credential in cleartext is only acceptable when it cannot + // leave the host, which is the local development and tutorial case. + let transport_protects_the_credential = match self.base_url.scheme() { + "https" => true, + "http" => self + .base_url + .host() + .is_some_and(|host| matches!(host, url::Host::Ipv4(ip) if ip.is_loopback())), + _ => false, + }; + if !transport_protects_the_credential { + return Err(EvidenceClientError::configuration( + "the base URL must use HTTPS, or HTTP with a numeric loopback host", + )); + } + if self.max_response_bytes == 0 { + return Err(EvidenceClientError::configuration( + "the response bound must allow at least one byte", + )); + } + if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + return Err(EvidenceClientError::configuration( + "the timeouts must be greater than zero", + )); + } + Ok(()) + } +} + +impl fmt::Debug for EvidenceClientConfig { + /// The key set, the credential source, and the pinned certificate material + /// are all withheld. Only the operational choices are rendered. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EvidenceClientConfig") + .field("base_url", &self.base_url.as_str()) + .field("request_timeout", &self.request_timeout) + .field("connect_timeout", &self.connect_timeout) + .field("user_agent", &self.user_agent) + .field("max_response_bytes", &self.max_response_bytes) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token::StaticToken; + + fn config(base_url: &str) -> EvidenceClientConfig { + EvidenceClientConfig::new( + Url::parse(base_url).expect("the test URL parses"), + Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), + JwksDocument { keys: Vec::new() }, + ) + } + + #[test] + fn https_and_loopback_http_are_accepted() { + for base_url in [ + "https://evidence.example.org", + "https://evidence.example.org/prefix/", + "http://127.0.0.1:8080", + "http://127.0.0.1:8080/", + ] { + config(base_url) + .validate() + .unwrap_or_else(|error| panic!("{base_url} was refused: {error}")); + } + } + + #[test] + fn a_base_url_that_cannot_protect_the_credential_is_refused() { + for base_url in [ + "http://evidence.example.org", + "http://localhost:8080", + "http://127.0.0.2.nip.io:8080", + "ftp://evidence.example.org", + ] { + assert!( + config(base_url).validate().is_err(), + "{base_url} was accepted" + ); + } + } + + #[test] + fn a_base_url_carrying_more_than_an_origin_and_path_is_refused() { + for base_url in [ + "https://user:pass@evidence.example.org", + "https://evidence.example.org/?tenant=1", + "https://evidence.example.org/#fragment", + ] { + assert!( + config(base_url).validate().is_err(), + "{base_url} was accepted" + ); + } + } + + #[test] + fn unusable_bounds_are_refused() { + assert!(config("https://evidence.example.org") + .with_max_response_bytes(0) + .validate() + .is_err()); + assert!(config("https://evidence.example.org") + .with_request_timeout(Duration::ZERO) + .validate() + .is_err()); + assert!(config("https://evidence.example.org") + .with_connect_timeout(Duration::ZERO) + .validate() + .is_err()); + } + + #[test] + fn debug_output_withholds_the_trust_and_credential_material() { + let config = config("https://evidence.example.org") + .with_trusted_root_certificates(b"-----BEGIN CERTIFICATE-----canary".to_vec()); + let rendered = format!("{config:?}"); + assert!(!rendered.contains("canary"), "{rendered}"); + assert!(!rendered.contains("token"), "{rendered}"); + assert!( + rendered.contains("https://evidence.example.org/"), + "{rendered}" + ); + } +} diff --git a/crates/registry-evidence-client/src/definitions.rs b/crates/registry-evidence-client/src/definitions.rs new file mode 100644 index 000000000..eeb45383e --- /dev/null +++ b/crates/registry-evidence-client/src/definitions.rs @@ -0,0 +1,393 @@ +//! The requester-scoped definitions document, as discovery returns it. +//! +//! Discovery answers one question: which complete request shapes may this +//! requester send. It is not a trust anchor and it grants no authority. The +//! values here are useful for authoring a relying procedure once, after which +//! the procedure itself, not a fresh discovery response, supplies the +//! verification expectations for every request. +//! +//! These types are owned here rather than imported from the runtime. The +//! integration suite proves they agree with a real deployment. + +use registry_evidence_verifier::{ + verifier::{ + ExpectedFormDocument, ExpectedListDocument, ExpectedListFormDocument, + ExpectedOutputDocument, ExpectedScalarFormDocument, + }, + AssuranceProfile, +}; +use serde::{Deserialize, Serialize}; + +pub const EVIDENCE_DEFINITIONS_SCHEMA_V1: &str = "registry.evidence-definitions/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitionsDocument { + pub schema: String, + pub assurance_profile: AssuranceProfile, + pub configuration_revision: String, + pub issued_by: String, + pub provided_by: String, + pub definitions: Vec, +} + +impl EvidenceDefinitionsDocument { + /// The single definition for one requirement identifier, when the + /// requester is entitled to exactly one shape of it. + #[must_use] + pub fn definition(&self, requirement: &str) -> Option<&EvidenceDefinition> { + self.definitions + .iter() + .find(|definition| definition.requirement == requirement) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinition { + pub requirement: String, + pub kind: DefinitionKind, + pub evidence_type: String, + pub purpose: String, + pub reference_frameworks: Vec, + pub subjects: Vec, + pub concepts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DefinitionKind { + Criterion, + InformationRequirement, + Constraint, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DefinitionSubject { + pub role: String, + pub cardinality: DefinitionCardinality, + pub selector: DefinitionSelector, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DefinitionCardinality { + One, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DefinitionSelector { + pub profile: String, + pub value_origin: SelectorValueOrigin, + pub fields: Vec, +} + +/// Where a selector's values come from. Only `Request` values are carried in +/// the request body; the other two are resolved from the authenticated caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SelectorValueOrigin { + Request, + AuthenticatedContext, + AuthenticatedGrant, +} + +/// Public validation metadata for one selector field. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SelectorField { + String { + name: String, + #[serde(rename = "minimumBytes")] + minimum_bytes: u64, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, + Date { + name: String, + }, + Integer { + name: String, + minimum: i64, + maximum: i64, + }, + Boolean { + name: String, + }, + ControlledCode { + name: String, + scheme: String, + version: String, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, +} + +impl SelectorField { + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::String { name, .. } + | Self::Date { name } + | Self::Integer { name, .. } + | Self::Boolean { name } + | Self::ControlledCode { name, .. } => name, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DefinitionConcept { + pub id: String, + pub form: ConceptForm, +} + +impl DefinitionConcept { + /// The verification expectation for a concept whose declared form is a + /// scalar. Returns `None` for the two collection forms, whose expectation + /// needs the cardinality the relying procedure states. + #[must_use] + pub fn scalar_expected_output(&self) -> Option { + self.form.scalar_form().map(|form| ExpectedOutputDocument { + concept: self.id.clone(), + form: ExpectedFormDocument::Scalar(form), + }) + } + + /// The verification expectation for a concept whose declared form is a + /// collection. The bounds come from the relying procedure, not from + /// discovery, which does not publish them. + #[must_use] + pub fn list_expected_output( + &self, + minimum_items: usize, + maximum_items: usize, + ) -> Option { + self.form.is_list().then(|| ExpectedOutputDocument { + concept: self.id.clone(), + form: ExpectedFormDocument::List(ExpectedListFormDocument { + list: ExpectedListDocument { + minimum_items, + maximum_items, + }, + }), + }) + } +} + +/// The declared public form of one concept. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConceptForm { + Boolean, + ControlledCode, + ControlledCategory, + BoundedInteger, + BoundedDecimal, + DateBucket, + TimeBucket, + AudienceScopedEntityReference, + ControlledCodeList, + EntityReferenceList, + ReviewedStructuredValue, +} + +impl ConceptForm { + /// The published value form each declared concept form takes on the wire. + /// + /// The mapping is stated by `products/evidence/contracts/` + /// `supported-value-forms.yaml`. A bounded decimal is a JSON string + /// carrying canonical decimal text, so its expected form is a string. + #[must_use] + pub fn scalar_form(self) -> Option { + match self { + Self::Boolean => Some(ExpectedScalarFormDocument::Boolean), + Self::ControlledCode | Self::ControlledCategory | Self::BoundedDecimal => { + Some(ExpectedScalarFormDocument::String) + } + Self::BoundedInteger => Some(ExpectedScalarFormDocument::Integer), + Self::DateBucket => Some(ExpectedScalarFormDocument::DateBucket), + Self::TimeBucket => Some(ExpectedScalarFormDocument::TimeBucket), + Self::AudienceScopedEntityReference => { + Some(ExpectedScalarFormDocument::EntityReference) + } + Self::ReviewedStructuredValue => Some(ExpectedScalarFormDocument::Structured), + Self::ControlledCodeList | Self::EntityReferenceList => None, + } + } + + #[must_use] + pub fn is_list(self) -> bool { + matches!(self, Self::ControlledCodeList | Self::EntityReferenceList) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCUMENT: &str = r#"{ + "schema": "registry.evidence-definitions/v1", + "assuranceProfile": "local", + "configurationRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "issuedBy": "urn:example:client:issuer", + "providedBy": "urn:example:client:provider", + "definitions": [ + { + "requirement": "urn:example:client:requirement:status:v1", + "kind": "criterion", + "evidenceType": "urn:example:client:evidence-type:status:v1", + "purpose": "example-decision", + "referenceFrameworks": ["urn:example:client:framework:status:v1"], + "subjects": [ + { + "role": "subject", + "cardinality": "one", + "selector": { + "profile": "record-lookup-v1", + "valueOrigin": "request", + "fields": [ + {"type": "string", "name": "record_reference", "minimumBytes": 1, "maximumBytes": 200}, + {"type": "date", "name": "recorded_on"}, + {"type": "integer", "name": "sequence", "minimum": 0, "maximum": 10}, + {"type": "boolean", "name": "confirmed"}, + {"type": "controlled-code", "name": "office", "scheme": "urn:example:client:scheme:office", "version": "1", "maximumBytes": 32} + ] + } + } + ], + "concepts": [{"id": "urn:example:client:concept:status-holds", "form": "boolean"}] + } + ] + }"#; + + fn document() -> EvidenceDefinitionsDocument { + serde_json::from_str(DOCUMENT).expect("the discovery document parses") + } + + #[test] + fn the_discovery_document_parses_into_closed_types() { + let document = document(); + assert_eq!(document.schema, EVIDENCE_DEFINITIONS_SCHEMA_V1); + assert_eq!(document.assurance_profile, AssuranceProfile::Local); + let definition = document + .definition("urn:example:client:requirement:status:v1") + .expect("the requirement is present"); + assert_eq!(definition.kind, DefinitionKind::Criterion); + assert_eq!( + definition.subjects[0].cardinality, + DefinitionCardinality::One + ); + assert_eq!( + definition.subjects[0].selector.value_origin, + SelectorValueOrigin::Request + ); + assert_eq!( + definition.subjects[0] + .selector + .fields + .iter() + .map(SelectorField::name) + .collect::>(), + [ + "record_reference", + "recorded_on", + "sequence", + "confirmed", + "office" + ] + ); + assert!(document + .definition("urn:example:client:requirement:absent") + .is_none()); + } + + #[test] + fn an_undeclared_member_is_refused() { + let extended = DOCUMENT.replace( + r#""schema": "registry.evidence-definitions/v1","#, + r#""schema": "registry.evidence-definitions/v1", "sourcePlan": "leaked","#, + ); + assert!(serde_json::from_str::(&extended).is_err()); + } + + #[test] + fn every_scalar_concept_form_maps_to_one_expected_value_form() { + let cases = [ + (ConceptForm::Boolean, ExpectedScalarFormDocument::Boolean), + ( + ConceptForm::ControlledCode, + ExpectedScalarFormDocument::String, + ), + ( + ConceptForm::ControlledCategory, + ExpectedScalarFormDocument::String, + ), + ( + ConceptForm::BoundedDecimal, + ExpectedScalarFormDocument::String, + ), + ( + ConceptForm::BoundedInteger, + ExpectedScalarFormDocument::Integer, + ), + ( + ConceptForm::DateBucket, + ExpectedScalarFormDocument::DateBucket, + ), + ( + ConceptForm::TimeBucket, + ExpectedScalarFormDocument::TimeBucket, + ), + ( + ConceptForm::AudienceScopedEntityReference, + ExpectedScalarFormDocument::EntityReference, + ), + ( + ConceptForm::ReviewedStructuredValue, + ExpectedScalarFormDocument::Structured, + ), + ]; + for (form, expected) in cases { + let concept = DefinitionConcept { + id: "urn:example:client:concept:one".to_owned(), + form, + }; + let output = concept + .scalar_expected_output() + .expect("a scalar form has a scalar expectation"); + // The verification policy types carry no equality, so the wire + // form they serialize to is what these tests compare. + assert_eq!( + serde_json::to_value(&output.form).expect("the form serializes"), + serde_json::to_value(ExpectedFormDocument::Scalar(expected)) + .expect("the form serializes") + ); + assert!(concept.list_expected_output(1, 2).is_none()); + } + } + + #[test] + fn a_collection_concept_form_needs_caller_supplied_bounds() { + for form in [ + ConceptForm::ControlledCodeList, + ConceptForm::EntityReferenceList, + ] { + let concept = DefinitionConcept { + id: "urn:example:client:concept:many".to_owned(), + form, + }; + assert!(concept.scalar_expected_output().is_none()); + let output = concept + .list_expected_output(1, 4) + .expect("a collection form has a collection expectation"); + assert_eq!( + serde_json::to_value(&output.form).expect("the form serializes"), + serde_json::json!({"list": {"minimumItems": 1, "maximumItems": 4}}) + ); + } + } +} diff --git a/crates/registry-evidence-client/src/error.rs b/crates/registry-evidence-client/src/error.rs new file mode 100644 index 000000000..83958a046 --- /dev/null +++ b/crates/registry-evidence-client/src/error.rs @@ -0,0 +1,141 @@ +//! One coarse failure type for the whole client. +//! +//! Every variant is deliberately uninformative about response content. A +//! rendering carries the HTTP status, the closed public problem code, and the +//! opaque operation identifier for support correlation, and nothing else. It +//! never carries response bytes, a credential, a header value, a selector +//! value, or a subject binding. + +use registry_evidence_verifier::verifier::VerificationError; +use thiserror::Error; + +use crate::{nonce::NonceError, token::TokenError}; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum EvidenceClientError { + /// The client configuration or the request specification is unusable. The + /// reason is fixed text chosen here, never caller data. + #[error("the client cannot be used as configured: {reason}")] + Configuration { reason: &'static str }, + + /// A request nonce could not be produced. + #[error(transparent)] + Nonce(#[from] NonceError), + + /// The token provider could not supply a usable credential. + #[error(transparent)] + Token(#[from] TokenError), + + /// The exchange did not complete. Connection setup, TLS negotiation, a + /// timeout, and a truncated body all collapse here. + #[error("the Evidence request did not complete: {kind}")] + Transport { kind: TransportKind }, + + /// The deployment refused the request. Authentication, authorization, and + /// rate limiting are indistinguishable by design at the public boundary. + #[error("the deployment refused the request: status {status}, code {code}")] + Denied { + status: u16, + code: String, + operation: Option, + /// Present only when the deployment asked for a bounded wait. + retry_after_seconds: Option, + }, + + /// The deployment could not produce evidence for this exact request. The + /// public contract collapses no match, ambiguity, a missing required fact, + /// and an unresolved derivation input into this one answer, so it must not + /// be read as a statement about the subject. + #[error("the deployment could not produce evidence for this request")] + NotAvailable { operation: Option }, + + /// The response did not satisfy the wire contract, or the deployment + /// reported a failure that is not a refusal. + #[error("the Evidence response does not satisfy the wire contract: status {status}")] + Protocol { + status: u16, + code: Option, + operation: Option, + }, + + /// Verification refused the response. The cause is the verifier's own + /// coarse reason, passed through unchanged. + #[error("the Evidence response failed verification: {0}")] + Verification(VerificationError), +} + +/// Coarse reason an exchange did not complete. +/// +/// TLS failures are reported as `Connect`: distinguishing them would mean +/// reading a transport error chain whose text this crate must not copy into a +/// diagnostic. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TransportKind { + #[error("connection setup failed")] + Connect, + #[error("the configured timeout elapsed")] + Timeout, + #[error("the exchange failed")] + Exchange, + #[error("the response body exceeded the configured maximum")] + ResponseTooLarge, +} + +impl EvidenceClientError { + pub(crate) fn configuration(reason: &'static str) -> Self { + Self::Configuration { reason } + } + + pub(crate) fn transport(kind: TransportKind) -> Self { + Self::Transport { kind } + } + + /// The opaque per-request identifier to quote when asking the deployment + /// operator about this failure. + #[must_use] + pub fn operation(&self) -> Option<&str> { + match self { + Self::Denied { operation, .. } + | Self::NotAvailable { operation } + | Self::Protocol { operation, .. } => operation.as_deref(), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renderings_carry_only_the_public_problem_members() { + let denied = EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: Some("01JZZZOPERATION".to_owned()), + retry_after_seconds: None, + }; + assert_eq!( + denied.to_string(), + "the deployment refused the request: status 403, code not_authorized" + ); + assert_eq!(denied.operation(), Some("01JZZZOPERATION")); + + let unavailable = EvidenceClientError::NotAvailable { + operation: Some("01JZZZOPERATION".to_owned()), + }; + assert_eq!( + unavailable.to_string(), + "the deployment could not produce evidence for this request" + ); + + let transport = EvidenceClientError::transport(TransportKind::ResponseTooLarge); + assert_eq!( + transport.to_string(), + "the Evidence request did not complete: the response body exceeded the configured maximum" + ); + assert_eq!(transport.operation(), None); + } +} diff --git a/crates/registry-evidence-client/src/fixtures.rs b/crates/registry-evidence-client/src/fixtures.rs new file mode 100644 index 000000000..0ff6c05ee --- /dev/null +++ b/crates/registry-evidence-client/src/fixtures.rs @@ -0,0 +1,203 @@ +//! Test-only issuer fixtures for this crate's own verification tests. +//! +//! The verifier crate has fixtures of its own, but they are private to it, so +//! this module signs its own inputs. It mirrors only what verification reads: +//! the protected header bytes, the signing input, and a payload that satisfies +//! the published Version 1 contract. +//! +//! What it deliberately omits: the runtime's issuer-side configuration guards +//! (key identifier validation, the check that the published key repeats the +//! provider's algorithm and identifier, and the startup sign-and-verify +//! self-test), and the published-key bound the verifier enforces. That bound is +//! private to the verifier, so a fixture here cannot assert against it; instead +//! each fixture publishes exactly one key, which is far below any bound either +//! side could impose. The runtime signer is proven against the verifier by the +//! runtime's own suite, and this crate's integration suite drives that runtime. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use ed25519_dalek::SigningKey; +use registry_evidence_verifier::{ + model::JwksDocument, EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, +}; +use registry_platform_crypto::{sign, PrivateJwk}; +use serde_json::{json, Value}; + +/// Vocabulary the fixtures assert about. It is deliberately abstract: this +/// crate carries no source product and no requirement domain. +pub(crate) const REQUIREMENT: &str = "urn:example:client:requirement:status:v1"; +pub(crate) const EVIDENCE_TYPE: &str = "urn:example:client:evidence-type:status:v1"; +pub(crate) const ISSUED_BY: &str = "urn:example:client:issuer"; +pub(crate) const PROVIDED_BY: &str = "urn:example:client:provider"; +pub(crate) const AUDIENCE: &str = "urn:example:client:audience:relying-party"; +pub(crate) const PURPOSE: &str = "example-decision"; +pub(crate) const CONCEPT: &str = "urn:example:client:concept:status-holds"; +pub(crate) const CONFIGURATION_REVISION: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +/// A binding as the runtime publishes it: the versioned prefix followed by the +/// unpadded base64url encoding of 32 bytes. +pub(crate) const SUBJECT_BINDING: &str = + "urn:evidence:subject:v1_QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU"; +/// The instant every fixture assertion is centered on. +pub(crate) const FIXTURE_INSTANT: &str = "2026-08-05T12:00:00Z"; +/// Longest lifetime the fixture assertions stay within. +pub(crate) const MAXIMUM_LIFETIME_SECONDS: u64 = 300; + +/// One issuer with one published key, and the assertions it will sign. +pub(crate) struct SignedEvidenceFixture { + signing_key: PrivateJwk, + key_id: String, + pub(crate) trusted_jwks: JwksDocument, + pub(crate) subject_binding: String, + pub(crate) now: DateTime, +} + +/// A fresh issuer. Two fixtures never share a key or a key identifier, so a +/// response from one is a response signed by a key the other never pinned. +pub(crate) fn signed_evidence() -> SignedEvidenceFixture { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("the test host supplies randomness"); + let signing_key = SigningKey::from_bytes(&seed); + let public = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); + let private = URL_SAFE_NO_PAD.encode(signing_key.to_bytes()); + let key_id = format!("fixture-key-{}", &public[..8]); + + let signing_key = PrivateJwk::parse( + &json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": key_id, + "x": public, + "d": private, + }) + .to_string(), + ) + .expect("the fixture key parses"); + let trusted_jwks = JwksDocument { + keys: vec![ + serde_json::to_value(signing_key.public()).expect("the published key serializes") + ], + }; + + SignedEvidenceFixture { + signing_key, + key_id, + trusted_jwks, + subject_binding: SUBJECT_BINDING.to_owned(), + now: FIXTURE_INSTANT + .parse::>() + .expect("the fixture instant parses"), + } +} + +impl SignedEvidenceFixture { + /// A signed assertion answering the request that carried this nonce. + pub(crate) fn sign(&self, request_nonce: &str) -> Vec { + self.sign_payload(&self.payload(request_nonce, PURPOSE)) + } + + /// A signed assertion whose stated purpose is not the one the relying party + /// asked for. + pub(crate) fn sign_with_purpose(&self, request_nonce: &str, purpose: &str) -> Vec { + self.sign_payload(&self.payload(request_nonce, purpose)) + } + + /// A signed assertion about a different subject, with everything else the + /// same. + pub(crate) fn sign_with_subject_binding(&self, request_nonce: &str, binding: &str) -> Vec { + self.sign_with_subjects( + request_nonce, + json!([{"role": "subject", "binding": binding}]), + ) + } + + /// A signed assertion carrying an arbitrary subject set, so a test can state + /// a role the request never asked for. + pub(crate) fn sign_with_subjects(&self, request_nonce: &str, subjects: Value) -> Vec { + let mut payload = self.payload(request_nonce, PURPOSE); + payload["subjects"] = subjects; + self.sign_payload(&payload) + } + + fn payload(&self, request_nonce: &str, purpose: &str) -> Value { + let issued_at = self.now; + let observed_at = issued_at - TimeDelta::try_seconds(60).expect("the offset is valid"); + let lifetime = i64::try_from(MAXIMUM_LIFETIME_SECONDS).expect("the lifetime fits"); + let valid_until = + issued_at + TimeDelta::try_seconds(lifetime).expect("the offset is valid"); + json!({ + "schema": EVIDENCE_SCHEMA_V1, + "assuranceProfile": "local", + "requestNonce": request_nonce, + "id": "urn:example:client:evidence:00000000-0000-4000-8000-000000000001", + "type": "Evidence", + "supportsRequirement": REQUIREMENT, + "isConformantTo": EVIDENCE_TYPE, + "issuedBy": ISSUED_BY, + "providedBy": PROVIDED_BY, + "issuedAt": rfc3339(issued_at), + "observedAt": rfc3339(observed_at), + "validUntil": rfc3339(valid_until), + "purpose": purpose, + "audience": AUDIENCE, + "configurationRevision": CONFIGURATION_REVISION, + "subjects": [{"role": "subject", "binding": self.subject_binding}], + "supportedValues": [{"providesValueFor": CONCEPT, "value": true}], + }) + } + + /// The flattened JWS serialization, as the response body carries it. + fn sign_payload(&self, payload: &Value) -> Vec { + let protected = URL_SAFE_NO_PAD.encode( + json!({ + "alg": "EdDSA", + "kid": self.key_id, + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY, + }) + .to_string(), + ); + let payload = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(payload).expect("the fixture payload serializes")); + let signing_input = [protected.as_bytes(), b".", payload.as_bytes()].concat(); + let signature = sign(&signing_input, &self.signing_key).expect("the fixture key signs"); + serde_json::to_vec(&json!({ + "protected": protected, + "payload": payload, + "signature": URL_SAFE_NO_PAD.encode(signature), + })) + .expect("the flattened JWS serializes") + } +} + +fn rfc3339(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_evidence_verifier::contracts::evidence_contract_accepts; + + /// The fixture payload is the shape the published contract accepts, so a + /// test that fails does so for the reason it names and not because the + /// fixture was malformed. + #[test] + fn the_fixture_payload_satisfies_the_published_contract() { + let fixture = signed_evidence(); + let payload = fixture.payload("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", PURPOSE); + assert!( + evidence_contract_accepts(&payload).expect("the contract validator initializes"), + "{payload}" + ); + } + + #[test] + fn two_fixtures_publish_different_keys() { + let first = signed_evidence(); + let second = signed_evidence(); + assert_ne!(first.key_id, second.key_id); + assert_ne!(first.trusted_jwks, second.trusted_jwks); + } +} diff --git a/crates/registry-evidence-client/src/lib.rs b/crates/registry-evidence-client/src/lib.rs new file mode 100644 index 000000000..1efa6ebcf --- /dev/null +++ b/crates/registry-evidence-client/src/lib.rs @@ -0,0 +1,106 @@ +//! Relying-party client for signed Evidence responses. +//! +//! This crate asks an Evidence deployment for an assertion over HTTP and then +//! verifies the answer offline with [`registry_evidence_verifier`]. It +//! re-implements no part of evaluation, signing, or verification: every +//! judgement about a response is the portable verifier's, applied to a policy +//! the caller closed before the request was sent. +//! +//! # The shape of one exchange +//! +//! ```text +//! prepare(spec) -> nonce + closed policy (no I/O) +//! send(prepared) -> signed response bytes (exactly one request) +//! verify(prepared, response) -> Evidence (offline, pinned keys) +//! ``` +//! +//! [`EvidenceClient::request_and_verify`] performs the last two steps together. +//! The split exists so a relying party can retain the exact bytes it verified. +//! +//! # The published key set is discovery, not a trust anchor +//! +//! The trusted key set is the one an integrator pins in +//! [`EvidenceClientConfig::new`], out of band, and it is the only key set +//! verification ever consults. [`EvidenceClient::fetch_jwks`] exists to support +//! that out-of-band workflow: fetch the deployment's published keys once, review +//! them against what the operator published elsewhere, and configure the +//! reviewed set. Nothing in this crate fetches keys at verification time. A key +//! set retrieved from the same origin as the response it would verify +//! establishes nothing about that response. +//! +//! # Subject bindings, and what a verified response proves +//! +//! An Evidence payload names each subject by a role-bound opaque binding. The +//! deployment computes it with a secret only it holds, so a relying party cannot +//! derive the binding for a subject it has never seen, and the verifier requires +//! the subject set to match the policy exactly. That leaves two honest options, +//! both in [`SubjectExpectations`]: +//! +//! - [`SubjectExpectations::Pinned`]: the relying party already holds the +//! bindings for these roles. Only here does a verified response prove that the +//! assertion is about the subject the relying party meant. +//! - [`SubjectExpectations::AcceptFirstUse`]: adopt the bindings this response +//! carries, verify everything else against the closed policy, then persist +//! [`VerifiedEvidence::pinned_subject_expectations`] and pin them from then +//! on. First use does not prove subject identity. It accepts the deployment's +//! own answer to the identity question once, and turns every later answer +//! about a different subject into a verification failure. It adopts bindings +//! only for exactly the roles the request asked about, once each; a response +//! that renames, adds, or drops a role is refused rather than adopted. +//! +//! There is no third option, and this crate does not offer a way around the +//! verifier's subject comparison. +//! +//! # One request, no retries +//! +//! A nonce identifies exactly one request and a policy accepts exactly the +//! answer to that request. Neither this crate nor its HTTP client retries +//! anything: a second attempt is a second [`EvidenceClient::prepare`] with a +//! fresh nonce. + +pub mod client; +pub mod config; +pub mod definitions; +pub mod error; +pub mod nonce; +pub mod prepare; +pub mod problem; +pub mod request; +pub mod token; + +#[cfg(test)] +mod fixtures; + +pub use client::{EvidenceClient, RawEvidenceResponse, VerifiedEvidence}; +pub use config::{ + EvidenceClientConfig, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_RESPONSE_BYTES, + DEFAULT_REQUEST_TIMEOUT, +}; +pub use definitions::{ + ConceptForm, DefinitionCardinality, DefinitionConcept, DefinitionKind, DefinitionSelector, + DefinitionSubject, EvidenceDefinition, EvidenceDefinitionsDocument, SelectorField, + SelectorValueOrigin, EVIDENCE_DEFINITIONS_SCHEMA_V1, +}; +pub use error::{EvidenceClientError, TransportKind}; +pub use nonce::{NonceError, RequestNonce}; +pub use prepare::{ + EvidenceRequestSpec, PreparedEvidenceRequest, SubjectExpectations, SubjectRequest, +}; +pub use request::SelectorValue; +pub use token::{BearerToken, StaticToken, TokenError, TokenProvider}; + +// The verification seam, re-exported so a relying party does not have to depend +// on the verifier crate directly to name the types this API returns and accepts. +pub use registry_evidence_verifier::{ + model::{ + BucketForm, BucketValue, EntityReferenceForm, EntityReferenceValue, Evidence, + EvidenceObjectType, JwksDocument, PublicValue, ScalarOrEntityReference, StructuredValue, + StructuredValueForm, SubjectBinding, SupportedValue, + }, + verifier::{ + EvidenceVerificationPolicyDocument, ExpectedFormDocument, ExpectedListDocument, + ExpectedListFormDocument, ExpectedOutputDocument, ExpectedScalarFormDocument, + ExpectedSubjectDocument, VerificationError, + }, + AssuranceProfile, +}; diff --git a/crates/registry-evidence-client/src/nonce.rs b/crates/registry-evidence-client/src/nonce.rs new file mode 100644 index 000000000..2446a0b7f --- /dev/null +++ b/crates/registry-evidence-client/src/nonce.rs @@ -0,0 +1,135 @@ +//! Canonical Evidence request nonce. +//! +//! The frozen request contract accepts exactly one encoding: the unpadded +//! base64url form of 32 bytes from a cryptographically secure random source. +//! The nonce is uninterpreted correlation data. It must never carry an +//! identifier, a selector value, a secret, or a document digest. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use thiserror::Error; + +/// Exactly 32 random bytes per request, as the contract requires. +const DECODED_BYTES: usize = 32; +/// Unpadded base64url of 32 bytes is always 43 characters. +const ENCODED_CHARACTERS: usize = 43; + +/// One canonical request nonce. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestNonce(String); + +impl RequestNonce { + /// Draw a fresh nonce from the operating system random source. + /// + /// Every prepared request gets its own value. Reusing one across two + /// requests would break the correlation the verifier depends on. + pub fn generate() -> Result { + let mut bytes = [0_u8; DECODED_BYTES]; + getrandom::fill(&mut bytes).map_err(|_| NonceError::Entropy)?; + Ok(Self(URL_SAFE_NO_PAD.encode(bytes))) + } + + /// Accept an externally retained nonce, such as one read back from a + /// relying party's own request record. + pub fn parse(value: &str) -> Result { + if is_canonical(value) { + Ok(Self(value.to_owned())) + } else { + Err(NonceError::NotCanonical) + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Whether a value is the canonical 43-character unpadded base64url encoding +/// of exactly 32 bytes. Padding, wrong length, a byte outside the alphabet, +/// and a noncanonical final symbol all fail, matching the runtime rule the +/// request contract states. +fn is_canonical(value: &str) -> bool { + if value.len() != ENCODED_CHARACTERS + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return false; + } + match URL_SAFE_NO_PAD.decode(value) { + Ok(decoded) => decoded.len() == DECODED_BYTES, + Err(_) => false, + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NonceError { + #[error("the system random source refused to supply request nonce bytes")] + Entropy, + #[error("the request nonce is not the canonical encoding of 32 bytes")] + NotCanonical, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_generated_nonce_is_canonical() { + let nonce = RequestNonce::generate().expect("the system random source works"); + assert_eq!(nonce.as_str().len(), ENCODED_CHARACTERS); + assert!(nonce + .as_str() + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))); + assert_eq!( + URL_SAFE_NO_PAD + .decode(nonce.as_str()) + .expect("a generated nonce decodes") + .len(), + DECODED_BYTES + ); + assert_eq!(RequestNonce::parse(nonce.as_str()), Ok(nonce)); + } + + #[test] + fn two_generated_nonces_differ() { + let first = RequestNonce::generate().expect("the system random source works"); + let second = RequestNonce::generate().expect("the system random source works"); + assert_ne!(first, second); + } + + #[test] + fn noncanonical_values_are_refused() { + // The final symbol of a 43-character encoding carries only two + // significant bits, so "AAAB" style tails are not canonical. + for candidate in [ + "", + "short", + &"A".repeat(ENCODED_CHARACTERS - 1), + &"A".repeat(ENCODED_CHARACTERS + 1), + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\u{00e9}", + ] { + assert_eq!( + RequestNonce::parse(candidate), + Err(NonceError::NotCanonical), + "candidate of {} bytes was accepted", + candidate.len() + ); + } + } + + #[test] + fn the_all_zero_nonce_is_canonical() { + // 43 'A' characters decode to 32 zero bytes. It is a legal encoding + // and only unacceptable because it is not random, which is a caller + // obligation this type cannot check. + assert!(is_canonical("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + } +} diff --git a/crates/registry-evidence-client/src/prepare.rs b/crates/registry-evidence-client/src/prepare.rs new file mode 100644 index 000000000..abadbf707 --- /dev/null +++ b/crates/registry-evidence-client/src/prepare.rs @@ -0,0 +1,694 @@ +//! Closing the expectations before the request exists. +//! +//! A relying party decides what an acceptable answer looks like from its own +//! trusted state: the relying procedure, a requirement contract it already +//! trusts, and subject bindings it already holds. `prepare` writes those +//! decisions into a verification policy document and generates the request +//! nonce, all before any byte leaves the process. Verification then compares the +//! response against a policy the response could not have influenced. + +use std::collections::BTreeSet; + +use registry_evidence_verifier::{ + verifier::{ + EvidenceVerificationPolicyDocument, ExpectedOutputDocument, ExpectedSubjectDocument, + }, + AssuranceProfile, +}; + +use crate::{ + error::EvidenceClientError, + nonce::RequestNonce, + request::{EvidenceRequestBody, RequestedSelector, RequestedSubject, SelectorValue}, +}; + +/// Largest role set one request may carry, per the request contract. +pub const MAXIMUM_SUBJECTS: usize = 8; +/// Largest selector value set one subject may carry, per the request contract. +pub const MAXIMUM_SELECTOR_VALUES: usize = 16; +/// Largest expected output set a policy may state. A Version 1 requirement +/// cannot publish more concepts than this. +pub const MAXIMUM_EXPECTED_OUTPUTS: usize = 16; +const MAXIMUM_IDENTIFIER_BYTES: usize = 512; +const MAXIMUM_SELECTOR_STRING_BYTES: usize = 512; + +/// One requested subject, before the request body exists. +#[derive(Debug, Clone)] +pub struct SubjectRequest { + pub role: String, + pub selector_profile: String, + /// Present only for a selector profile whose values originate in the + /// request. Discovery states each profile's value origin. + pub selector_values: Option>, +} + +/// What the relying party will accept, and from which request. +/// +/// Every field except `subjects` is an expectation. The values come from the +/// relying procedure; discovery is a convenient place to read them once while +/// authoring that procedure, never a per-request authority. +#[derive(Debug, Clone)] +pub struct EvidenceRequestSpec { + pub requirement: String, + pub purpose: String, + /// The relying party's own audience identifier, as the deployment + /// registered it. + pub audience: String, + /// The requirement's evidence type. The payload states it as + /// `isConformantTo`, and discovery publishes it as `evidenceType`. + pub evidence_type: String, + pub issued_by: String, + pub provided_by: String, + pub configuration_revision: String, + pub expected_assurance_profile: AssuranceProfile, + pub subjects: Vec, + pub expected_outputs: Vec, + pub maximum_assertion_lifetime_seconds: u64, + pub clock_skew_seconds: u64, + pub subject_expectations: SubjectExpectations, +} + +/// How the role-bound subject bindings in the response are to be judged. +/// +/// A binding is a keyed one-way value the deployment computes with a secret it +/// alone holds. A relying party therefore cannot derive the expected binding +/// for a subject it has never seen, and the verifier requires the subject set +/// to match exactly. That leaves two honest options, and this enum is both of +/// them. +#[derive(Clone)] +pub enum SubjectExpectations { + /// Bindings the relying party already holds for these roles, from an + /// out-of-band exchange or from an earlier accepted transaction. + /// + /// This is the only setting under which a verified response proves that the + /// assertion is about the subject the relying party meant. + Pinned(Vec), + + /// Adopt the bindings this response carries, then pin them. + /// + /// Verification still enforces every other expectation: the signature + /// against the pinned key set, the issuer, the provider, the requirement, + /// the evidence type, the purpose, the audience, the configuration + /// revision, the request nonce, the expected outputs and their forms, and + /// the validity interval. Only the subject set is taken from the payload. + /// + /// What this does not prove: that the assertion is about the subject the + /// relying party meant. The deployment resolved the selector, and this + /// setting accepts its answer for the identity question. It is the + /// first-contact case, modelled on re-verifying a retained response from an + /// accepted transaction: accept once, persist the bindings the verified + /// response exposes, and pass `Pinned` from then on, at which point a + /// changed subject becomes a verification failure. + AcceptFirstUse, +} + +impl std::fmt::Debug for SubjectExpectations { + /// A binding is a pseudonymous per-subject identifier, so only the shape of + /// the expectation is rendered. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pinned(subjects) => formatter + .debug_struct("Pinned") + .field("roles", &subjects.len()) + .finish_non_exhaustive(), + Self::AcceptFirstUse => formatter.write_str("AcceptFirstUse"), + } + } +} + +/// A request body and the closed policy that will judge its answer. +/// +/// One prepared request is good for exactly one exchange. A nonce reused across +/// two requests would let an answer to the first satisfy the policy for the +/// second, so retrying means preparing again. +#[derive(Clone)] +pub struct PreparedEvidenceRequest { + body: EvidenceRequestBody, + /// The policy with every expectation except the subject set, which + /// `subject_expectations` decides. + policy: EvidenceVerificationPolicyDocument, + subject_expectations: SubjectExpectations, +} + +impl PreparedEvidenceRequest { + /// Validate a specification, generate its nonce, and close its policy. + pub(crate) fn new(spec: EvidenceRequestSpec) -> Result { + validate(&spec)?; + let nonce = RequestNonce::generate()?; + + let subjects = spec + .subjects + .into_iter() + .map(|subject| RequestedSubject { + role: subject.role, + selector: RequestedSelector { + profile: subject.selector_profile, + values: subject + .selector_values + .map(|values| values.into_iter().collect()), + }, + }) + .collect(); + let body = EvidenceRequestBody { + request_nonce: nonce.as_str().to_owned(), + requirement: spec.requirement.clone(), + purpose: spec.purpose.clone(), + subjects, + }; + + let expected_subjects = match &spec.subject_expectations { + SubjectExpectations::Pinned(subjects) => subjects.clone(), + // The response has not been fetched, so there is nothing honest to + // put here yet. Verification substitutes the adopted set. + SubjectExpectations::AcceptFirstUse => Vec::new(), + }; + let policy = EvidenceVerificationPolicyDocument { + expected_assurance_profile: spec.expected_assurance_profile, + issued_by: spec.issued_by, + provided_by: spec.provided_by, + requirement: spec.requirement, + evidence_type: spec.evidence_type, + purpose: spec.purpose, + audience: spec.audience, + configuration_revision: spec.configuration_revision, + request_nonce: nonce.as_str().to_owned(), + expected_subjects, + expected_outputs: spec.expected_outputs, + maximum_assertion_lifetime_seconds: spec.maximum_assertion_lifetime_seconds, + clock_skew_seconds: spec.clock_skew_seconds, + }; + Ok(Self { + body, + policy, + subject_expectations: spec.subject_expectations, + }) + } + + /// The nonce this request carries. Retain it with the transaction record: + /// re-verifying the stored response later needs the nonce from the request, + /// not from the response. + #[must_use] + pub fn request_nonce(&self) -> &str { + &self.body.request_nonce + } + + /// The closed policy, with the subject set as `prepare` left it. It is + /// serializable, so a relying party can retain it beside the response. + #[must_use] + pub fn policy_document(&self) -> &EvidenceVerificationPolicyDocument { + &self.policy + } + + #[must_use] + pub fn subject_expectations(&self) -> &SubjectExpectations { + &self.subject_expectations + } + + pub(crate) fn body(&self) -> &EvidenceRequestBody { + &self.body + } + + /// The same policy with an explicit subject set. This is how first-use + /// acceptance reaches the ordinary verifier: the adopted bindings become + /// stated expectations, and nothing else about the policy changes. + /// + /// First use defers which subject an assertion is about, not which roles were + /// asked about. A claimed set that does not cover exactly the requested roles, + /// once each, is adopted as nothing at all, which leaves the verifier to + /// refuse the response on the policy it was given. + pub(crate) fn policy_with_subjects( + &self, + claimed_subjects: Vec, + ) -> EvidenceVerificationPolicyDocument { + let mut policy = self.policy.clone(); + policy.expected_subjects = if self.covers_requested_roles(&claimed_subjects) { + claimed_subjects + } else { + Vec::new() + }; + policy + } + + /// Whether a claimed subject set names exactly the requested roles, once + /// each. + fn covers_requested_roles(&self, claimed_subjects: &[ExpectedSubjectDocument]) -> bool { + claimed_subjects.len() == self.body.subjects.len() + && self.body.subjects.iter().all(|requested| { + claimed_subjects + .iter() + .filter(|claimed| claimed.role == requested.role) + .count() + == 1 + }) + } +} + +impl std::fmt::Debug for PreparedEvidenceRequest { + /// The selector values and the expected bindings are withheld. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedEvidenceRequest") + .field("requirement", &self.policy.requirement) + .field("request_nonce", &self.policy.request_nonce) + .field("subject_expectations", &self.subject_expectations) + .finish_non_exhaustive() + } +} + +/// Refuse a specification the deployment would refuse, or one whose policy +/// could not decide anything. +fn validate(spec: &EvidenceRequestSpec) -> Result<(), EvidenceClientError> { + for identifier in [ + &spec.requirement, + &spec.audience, + &spec.evidence_type, + &spec.issued_by, + &spec.provided_by, + &spec.configuration_revision, + ] { + if identifier.is_empty() || identifier.len() > MAXIMUM_IDENTIFIER_BYTES { + return Err(EvidenceClientError::configuration( + "every requirement, audience, issuer, provider, type, and revision identifier must be present and bounded", + )); + } + } + if !is_purpose(&spec.purpose) { + return Err(EvidenceClientError::configuration( + "the purpose must match the request contract's own lexical rule", + )); + } + if spec.subjects.is_empty() || spec.subjects.len() > MAXIMUM_SUBJECTS { + return Err(EvidenceClientError::configuration( + "a request must carry between one and eight subject roles", + )); + } + + let mut roles = BTreeSet::new(); + for subject in &spec.subjects { + if !is_role(&subject.role) || !roles.insert(subject.role.clone()) { + return Err(EvidenceClientError::configuration( + "each subject role must match the request contract's lexical rule and appear once", + )); + } + if !is_selector_profile(&subject.selector_profile) { + return Err(EvidenceClientError::configuration( + "each selector profile must match the request contract's lexical rule", + )); + } + validate_selector_values(subject.selector_values.as_deref())?; + } + + if spec.expected_outputs.is_empty() || spec.expected_outputs.len() > MAXIMUM_EXPECTED_OUTPUTS { + return Err(EvidenceClientError::configuration( + "a policy must expect between one and sixteen outputs", + )); + } + let mut concepts = BTreeSet::new(); + for output in &spec.expected_outputs { + if output.concept.is_empty() + || output.concept.len() > MAXIMUM_IDENTIFIER_BYTES + || !concepts.insert(output.concept.as_str()) + { + return Err(EvidenceClientError::configuration( + "each expected output must name a bounded concept once", + )); + } + } + + if spec.maximum_assertion_lifetime_seconds == 0 { + return Err(EvidenceClientError::configuration( + "the maximum assertion lifetime must be greater than zero", + )); + } + + if let SubjectExpectations::Pinned(pinned) = &spec.subject_expectations { + let pinned_roles: BTreeSet = pinned + .iter() + .filter(|subject| !subject.binding.is_empty()) + .map(|subject| subject.role.clone()) + .collect(); + // The verifier requires the subject sets to match exactly, so a policy + // that pins a different role set than the request asks for could never + // accept a well-formed answer. + if pinned.len() != pinned_roles.len() || pinned_roles != roles { + return Err(EvidenceClientError::configuration( + "the pinned subject bindings must cover exactly the requested roles, once each", + )); + } + } + + Ok(()) +} + +fn validate_selector_values( + values: Option<&[(String, SelectorValue)]>, +) -> Result<(), EvidenceClientError> { + let Some(values) = values else { + return Ok(()); + }; + if values.is_empty() || values.len() > MAXIMUM_SELECTOR_VALUES { + return Err(EvidenceClientError::configuration( + "a selector that carries values must carry between one and sixteen of them", + )); + } + let mut names = BTreeSet::new(); + for (name, value) in values { + if !is_selector_field_name(name) || !names.insert(name.as_str()) { + return Err(EvidenceClientError::configuration( + "each selector field name must match the request contract's lexical rule and appear once", + )); + } + if let SelectorValue::String(text) = value { + if text.is_empty() || text.len() > MAXIMUM_SELECTOR_STRING_BYTES { + return Err(EvidenceClientError::configuration( + "each selector string value must be present and bounded", + )); + } + } + } + Ok(()) +} + +/// `^[a-z][a-z0-9._:-]{0,127}$` +fn is_purpose(value: &str) -> bool { + bounded_lowercase(value, 128, |byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) +} + +/// `^[a-z][a-z0-9._-]{0,63}$` +fn is_role(value: &str) -> bool { + bounded_lowercase(value, 64, is_name_byte) +} + +/// `^[a-z][a-z0-9._-]{0,63}$` +fn is_selector_field_name(value: &str) -> bool { + bounded_lowercase(value, 64, is_name_byte) +} + +/// `^[a-z][a-z0-9._-]{0,127}$` +fn is_selector_profile(value: &str) -> bool { + bounded_lowercase(value, 128, is_name_byte) +} + +fn is_name_byte(byte: u8) -> bool { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') +} + +fn bounded_lowercase(value: &str, maximum_bytes: usize, acceptable: impl Fn(u8) -> bool) -> bool { + !value.is_empty() + && value.len() <= maximum_bytes + && value.starts_with(|character: char| character.is_ascii_lowercase()) + && value.bytes().all(acceptable) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_evidence_verifier::verifier::{ExpectedFormDocument, ExpectedScalarFormDocument}; + + fn expected_output() -> ExpectedOutputDocument { + ExpectedOutputDocument { + concept: "urn:example:client:concept:status-holds".to_owned(), + form: ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean), + } + } + + fn spec() -> EvidenceRequestSpec { + EvidenceRequestSpec { + requirement: "urn:example:client:requirement:status:v1".to_owned(), + purpose: "example-decision".to_owned(), + audience: "urn:example:client:audience:relying-party".to_owned(), + evidence_type: "urn:example:client:evidence-type:status:v1".to_owned(), + issued_by: "urn:example:client:issuer".to_owned(), + provided_by: "urn:example:client:provider".to_owned(), + configuration_revision: "sha256:00".to_owned(), + expected_assurance_profile: AssuranceProfile::Local, + subjects: vec![SubjectRequest { + role: "subject".to_owned(), + selector_profile: "record-lookup-v1".to_owned(), + selector_values: Some(vec![( + "record_reference".to_owned(), + SelectorValue::from("synthetic-record-001"), + )]), + }], + expected_outputs: vec![expected_output()], + maximum_assertion_lifetime_seconds: 300, + clock_skew_seconds: 60, + subject_expectations: SubjectExpectations::AcceptFirstUse, + } + } + + fn pinned() -> SubjectExpectations { + SubjectExpectations::Pinned(vec![ExpectedSubjectDocument { + role: "subject".to_owned(), + binding: "y0KMdWluZGluZw".to_owned(), + }]) + } + + #[test] + fn preparing_closes_the_policy_and_generates_the_nonce_before_any_exchange() { + let mut spec = spec(); + spec.subject_expectations = pinned(); + let prepared = PreparedEvidenceRequest::new(spec).expect("the specification is accepted"); + + // The nonce is one value, shared by the request the deployment sees and + // the policy that judges its answer. + assert_eq!(prepared.request_nonce().len(), 43); + assert_eq!( + prepared.policy_document().request_nonce, + prepared.request_nonce() + ); + assert_eq!(prepared.body().request_nonce, prepared.request_nonce()); + + let policy = serde_json::to_value(prepared.policy_document()) + .expect("the policy document serializes"); + assert_eq!( + policy, + serde_json::json!({ + "expectedAssuranceProfile": "local", + "issuedBy": "urn:example:client:issuer", + "providedBy": "urn:example:client:provider", + "requirement": "urn:example:client:requirement:status:v1", + "evidenceType": "urn:example:client:evidence-type:status:v1", + "purpose": "example-decision", + "audience": "urn:example:client:audience:relying-party", + "configurationRevision": "sha256:00", + "requestNonce": prepared.request_nonce(), + "expectedSubjects": [{"role": "subject", "binding": "y0KMdWluZGluZw"}], + "expectedOutputs": [{ + "concept": "urn:example:client:concept:status-holds", + "form": "boolean", + }], + "maximumAssertionLifetimeSeconds": 300, + "clockSkewSeconds": 60, + }) + ); + } + + #[test] + fn two_prepared_requests_never_share_a_nonce() { + let first = PreparedEvidenceRequest::new(spec()).expect("the specification is accepted"); + let second = PreparedEvidenceRequest::new(spec()).expect("the specification is accepted"); + assert_ne!(first.request_nonce(), second.request_nonce()); + } + + #[test] + fn first_use_acceptance_states_no_subject_until_a_response_supplies_one() { + let prepared = PreparedEvidenceRequest::new(spec()).expect("the specification is accepted"); + assert!(prepared.policy_document().expected_subjects.is_empty()); + assert!(matches!( + prepared.subject_expectations(), + SubjectExpectations::AcceptFirstUse + )); + + // Adopting a subject set changes only the subject set. + let adopted = prepared.policy_with_subjects(vec![ExpectedSubjectDocument { + role: "subject".to_owned(), + binding: "y0KMdWluZGluZw".to_owned(), + }]); + let mut before = + serde_json::to_value(prepared.policy_document()).expect("the policy serializes"); + let mut after = serde_json::to_value(&adopted).expect("the policy serializes"); + assert_eq!( + after["expectedSubjects"], + serde_json::json!([{"role": "subject", "binding": "y0KMdWluZGluZw"}]) + ); + before + .as_object_mut() + .expect("the policy is an object") + .remove("expectedSubjects"); + after + .as_object_mut() + .expect("the policy is an object") + .remove("expectedSubjects"); + assert_eq!(before, after); + } + + /// One named way of breaking an otherwise acceptable specification. + type Breakage = (&'static str, Box); + + #[test] + fn a_specification_the_deployment_would_refuse_is_refused_here() { + let cases: Vec = vec![ + ( + "an empty requirement", + Box::new(|spec| spec.requirement = String::new()), + ), + ( + "an empty audience", + Box::new(|spec| spec.audience = String::new()), + ), + ( + "an empty evidence type", + Box::new(|spec| spec.evidence_type = String::new()), + ), + ( + "an empty issuer", + Box::new(|spec| spec.issued_by = String::new()), + ), + ( + "an empty provider", + Box::new(|spec| spec.provided_by = String::new()), + ), + ( + "an empty configuration revision", + Box::new(|spec| spec.configuration_revision = String::new()), + ), + ( + "an oversized requirement", + Box::new(|spec| spec.requirement = "u".repeat(MAXIMUM_IDENTIFIER_BYTES + 1)), + ), + ( + "an uppercase purpose", + Box::new(|spec| spec.purpose = "Example-Decision".to_owned()), + ), + ( + "a purpose with a space", + Box::new(|spec| spec.purpose = "example decision".to_owned()), + ), + ( + "an empty purpose", + Box::new(|spec| spec.purpose = String::new()), + ), + ("no subject", Box::new(|spec| spec.subjects.clear())), + ( + "more subjects than the contract allows", + Box::new(|spec| { + spec.subjects = (0..MAXIMUM_SUBJECTS + 1) + .map(|index| SubjectRequest { + role: format!("role-{index}"), + selector_profile: "record-lookup-v1".to_owned(), + selector_values: None, + }) + .collect(); + }), + ), + ( + "a repeated role", + Box::new(|spec| { + let subject = spec.subjects[0].clone(); + spec.subjects.push(subject); + }), + ), + ( + "an uppercase role", + Box::new(|spec| spec.subjects[0].role = "Subject".to_owned()), + ), + ( + "a selector profile with a colon", + Box::new(|spec| spec.subjects[0].selector_profile = "record:lookup".to_owned()), + ), + ( + "a selector that announces values but carries none", + Box::new(|spec| spec.subjects[0].selector_values = Some(Vec::new())), + ), + ( + "an uppercase selector field name", + Box::new(|spec| { + spec.subjects[0].selector_values = Some(vec![( + "Record_Reference".to_owned(), + SelectorValue::from(1), + )]); + }), + ), + ( + "an empty selector string value", + Box::new(|spec| { + spec.subjects[0].selector_values = Some(vec![( + "record_reference".to_owned(), + SelectorValue::from(""), + )]); + }), + ), + ( + "no expected output", + Box::new(|spec| spec.expected_outputs.clear()), + ), + ( + "a repeated expected concept", + Box::new(|spec| spec.expected_outputs.push(expected_output())), + ), + ( + "a lifetime of zero", + Box::new(|spec| spec.maximum_assertion_lifetime_seconds = 0), + ), + ( + "a pinned role the request does not ask for", + Box::new(|spec| { + spec.subject_expectations = + SubjectExpectations::Pinned(vec![ExpectedSubjectDocument { + role: "other".to_owned(), + binding: "y0KMdWluZGluZw".to_owned(), + }]); + }), + ), + ( + "a pinned subject with no binding", + Box::new(|spec| { + spec.subject_expectations = + SubjectExpectations::Pinned(vec![ExpectedSubjectDocument { + role: "subject".to_owned(), + binding: String::new(), + }]); + }), + ), + ( + "no pinned subject at all", + Box::new(|spec| { + spec.subject_expectations = SubjectExpectations::Pinned(Vec::new()); + }), + ), + ]; + for (description, break_it) in cases { + let mut spec = spec(); + break_it(&mut spec); + assert!( + PreparedEvidenceRequest::new(spec).is_err(), + "{description} was accepted" + ); + } + } + + #[test] + fn a_selector_whose_values_come_from_the_authenticated_caller_carries_none() { + let mut spec = spec(); + spec.subjects[0].selector_values = None; + let prepared = PreparedEvidenceRequest::new(spec).expect("the specification is accepted"); + let body = serde_json::to_string(prepared.body()).expect("the body serializes"); + assert!(!body.contains("values"), "{body}"); + } + + #[test] + fn debug_output_withholds_selector_values_and_bindings() { + let mut spec = spec(); + spec.subject_expectations = pinned(); + let prepared = PreparedEvidenceRequest::new(spec).expect("the specification is accepted"); + let rendered = format!("{prepared:?}"); + assert!(!rendered.contains("synthetic-record-001"), "{rendered}"); + assert!(!rendered.contains("y0KMdWluZGluZw"), "{rendered}"); + assert!(rendered.contains("Pinned"), "{rendered}"); + } +} diff --git a/crates/registry-evidence-client/src/problem.rs b/crates/registry-evidence-client/src/problem.rs new file mode 100644 index 000000000..93c3ff01c --- /dev/null +++ b/crates/registry-evidence-client/src/problem.rs @@ -0,0 +1,288 @@ +//! The closed public problem body and its mapping onto client failures. +//! +//! The Evidence problem contract is frozen: exactly the members `type`, +//! `title`, `status`, `code`, and `operation`, and nothing that could describe +//! the request, the principal, the source, or the subject. This module parses +//! that body strictly and refuses anything else, so a body a deployment did +//! not promise cannot become a confident diagnostic. + +use serde::Deserialize; + +use crate::error::EvidenceClientError; + +pub(crate) const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; + +/// The public code for a request that produced no evidence. It deliberately +/// covers several internal outcomes. +const EVIDENCE_NOT_AVAILABLE: &str = "evidence_not_available"; + +/// Longest accepted problem body. The closed contract is far smaller. +pub(crate) const MAXIMUM_PROBLEM_BYTES: usize = 4 * 1024; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProblemBody { + #[serde(rename = "type")] + pub(crate) type_uri: String, + pub(crate) title: String, + pub(crate) status: u16, + pub(crate) code: String, + pub(crate) operation: String, +} + +/// Map a refused or failed exchange onto one coarse client failure. +/// +/// `retry_after_seconds` is read from the response header and honored only for +/// the rate-limited answer, which is the one case the contract permits it for. +pub(crate) fn map_problem( + status: u16, + media_type: Option<&str>, + body: &[u8], + retry_after_seconds: Option, +) -> EvidenceClientError { + let Some(problem) = parse_problem(media_type, body) else { + return EvidenceClientError::Protocol { + status, + code: None, + operation: None, + }; + }; + let operation = sanitized_operation(&problem.operation); + match (status, problem.code.as_str()) { + (401 | 403 | 429, code) => EvidenceClientError::Denied { + status, + code: code.to_owned(), + operation, + retry_after_seconds: retry_after_seconds.filter(|_| status == 429), + }, + (422, EVIDENCE_NOT_AVAILABLE) => EvidenceClientError::NotAvailable { operation }, + (_, code) => EvidenceClientError::Protocol { + status, + code: Some(code.to_owned()), + operation, + }, + } +} + +/// Parse a problem body that satisfies the closed contract exactly. +/// +/// A wrong media type, an oversized body, an unknown member, a missing member, +/// or a code outside the contract's own shape all yield `None`, which the +/// caller reports as a protocol failure rather than as a refusal it can +/// explain. +fn parse_problem(media_type: Option<&str>, body: &[u8]) -> Option { + if media_type.map(essence) != Some(PROBLEM_MEDIA_TYPE.to_owned()) + || body.is_empty() + || body.len() > MAXIMUM_PROBLEM_BYTES + { + return None; + } + let problem: ProblemBody = serde_json::from_slice(body).ok()?; + if !is_contract_code(&problem.code) { + return None; + } + Some(problem) +} + +/// The lowercase media type without parameters. +pub(crate) fn essence(value: &str) -> String { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} + +/// The contract's codes are lowercase snake case. Anything else is refused +/// before it can reach a diagnostic or a caller's own log line. +fn is_contract_code(code: &str) -> bool { + !code.is_empty() + && code.len() <= 64 + && code.starts_with(|character: char| character.is_ascii_lowercase()) + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') +} + +/// The operation identifier is an opaque support-correlation value. Only a +/// bounded alphanumeric value is kept, so a hostile deployment cannot use it to +/// inject text into a relying party's records. +fn sanitized_operation(operation: &str) -> Option { + let acceptable = !operation.is_empty() + && operation.len() <= 64 + && operation.bytes().all(|byte| byte.is_ascii_alphanumeric()); + acceptable.then(|| operation.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn problem_json(status: u16, code: &str) -> Vec { + format!( + r#"{{"type":"https://registrystack.org/problems/evidence","title":"Request is not authorized","status":{status},"code":"{code}","operation":"01JQ0QZ8YHZ0000000000000AB"}}"# + ) + .into_bytes() + } + + #[test] + fn refusals_map_to_the_denied_failure() { + for (status, code) in [ + (401_u16, "authentication_failed"), + (403, "not_authorized"), + (429, "rate_limited"), + ] { + let mapped = map_problem( + status, + Some(PROBLEM_MEDIA_TYPE), + &problem_json(status, code), + Some(1), + ); + assert_eq!( + mapped, + EvidenceClientError::Denied { + status, + code: code.to_owned(), + operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + // Only the rate-limited answer may carry a wait. + retry_after_seconds: (status == 429).then_some(1), + } + ); + } + } + + #[test] + fn the_unavailable_answer_maps_to_its_own_failure() { + let mapped = map_problem( + 422, + Some(PROBLEM_MEDIA_TYPE), + &problem_json(422, "evidence_not_available"), + None, + ); + assert_eq!( + mapped, + EvidenceClientError::NotAvailable { + operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + } + ); + } + + #[test] + fn other_contract_codes_map_to_a_protocol_failure() { + for (status, code) in [ + (400_u16, "malformed_request"), + (400, "invalid_selector"), + (406, "response_format_not_acceptable"), + (503, "dependency_unavailable"), + (503, "service_unavailable"), + // A 422 that is not the collapsed answer is not something this + // client can interpret. + (422, "malformed_request"), + ] { + let mapped = map_problem( + status, + Some(PROBLEM_MEDIA_TYPE), + &problem_json(status, code), + None, + ); + assert_eq!( + mapped, + EvidenceClientError::Protocol { + status, + code: Some(code.to_owned()), + operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + } + ); + } + } + + #[test] + fn a_body_outside_the_closed_contract_is_never_read_as_a_refusal() { + let unknown_member = br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized","operation":"01AB","hint":"subject not found"}"#; + let missing_member = + br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized"}"#; + let wrong_code_shape = br#"{"type":"about:blank","title":"t","status":403,"code":"Not Authorized: subject 42","operation":"01AB"}"#; + for body in [ + unknown_member.as_slice(), + missing_member.as_slice(), + wrong_code_shape.as_slice(), + b"not json".as_slice(), + b"".as_slice(), + ] { + assert_eq!( + map_problem(403, Some(PROBLEM_MEDIA_TYPE), body, None), + EvidenceClientError::Protocol { + status: 403, + code: None, + operation: None, + } + ); + } + + // A body that does not announce itself as a problem document is not + // parsed at all, whatever it contains. + assert_eq!( + map_problem( + 403, + Some("application/json"), + &problem_json(403, "not_authorized"), + None + ), + EvidenceClientError::Protocol { + status: 403, + code: None, + operation: None, + } + ); + assert_eq!( + map_problem(403, None, &problem_json(403, "not_authorized"), None), + EvidenceClientError::Protocol { + status: 403, + code: None, + operation: None, + } + ); + } + + #[test] + fn an_oversized_problem_body_is_refused() { + let padded = format!( + r#"{{"type":"{}","title":"t","status":403,"code":"not_authorized","operation":"01AB"}}"#, + "a".repeat(MAXIMUM_PROBLEM_BYTES) + ); + assert_eq!( + map_problem(403, Some(PROBLEM_MEDIA_TYPE), padded.as_bytes(), None), + EvidenceClientError::Protocol { + status: 403, + code: None, + operation: None, + } + ); + } + + #[test] + fn an_unusable_operation_identifier_is_dropped_not_copied() { + let hostile = br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized","operation":"01AB\nsubject=Amina"}"#; + assert_eq!( + map_problem(403, Some(PROBLEM_MEDIA_TYPE), hostile, None), + EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: None, + retry_after_seconds: None, + } + ); + } + + #[test] + fn the_media_type_is_compared_without_its_parameters() { + let mapped = map_problem( + 403, + Some("application/problem+json; charset=utf-8"), + &problem_json(403, "not_authorized"), + None, + ); + assert!(matches!(mapped, EvidenceClientError::Denied { .. })); + } +} diff --git a/crates/registry-evidence-client/src/request.rs b/crates/registry-evidence-client/src/request.rs new file mode 100644 index 000000000..1d52e2173 --- /dev/null +++ b/crates/registry-evidence-client/src/request.rs @@ -0,0 +1,156 @@ +//! The Evidence request body, exactly as the frozen Version 1 wire contract +//! states it. +//! +//! These types are owned here rather than imported from the runtime: a relying +//! party links this crate and the portable verifier, never the service runtime. +//! The integration suite proves the two agree by driving a real deployment. +//! +//! `holderKey` is deliberately absent. It is meaningful only for the SD-JWT VC +//! response format, which this client does not request. + +use std::{collections::BTreeMap, fmt}; + +use serde::Serialize; + +/// One complete request body. +/// +/// `Debug` is redacted: the selector values are the caller's own identifying +/// input and must not reach a log line, a panic message, or a snapshot. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceRequestBody { + pub request_nonce: String, + pub requirement: String, + pub purpose: String, + /// Unordered role set encoded as an array. Each configured role appears + /// exactly once; array position carries no meaning. + pub subjects: Vec, +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct RequestedSubject { + pub role: String, + pub selector: RequestedSelector, +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct RequestedSelector { + pub profile: String, + /// Values are present only for a selector profile whose values originate + /// in the request. A profile that reads the authenticated context or an + /// authenticated grant must carry none. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option>, +} + +/// The three scalar shapes a selector value may take. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum SelectorValue { + String(String), + Integer(i64), + Boolean(bool), +} + +impl From<&str> for SelectorValue { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From for SelectorValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From for SelectorValue { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for SelectorValue { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +macro_rules! redacted_debug { + ($($type_name:ty),+ $(,)?) => { + $( + impl fmt::Debug for $type_name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct(stringify!($type_name)) + .finish_non_exhaustive() + } + } + )+ + }; +} + +redacted_debug!( + EvidenceRequestBody, + RequestedSubject, + RequestedSelector, + SelectorValue, +); + +#[cfg(test)] +mod tests { + use super::*; + + fn body() -> EvidenceRequestBody { + EvidenceRequestBody { + request_nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(), + requirement: "urn:example:client:requirement:status:v1".to_owned(), + purpose: "example-decision".to_owned(), + subjects: vec![RequestedSubject { + role: "subject".to_owned(), + selector: RequestedSelector { + profile: "record-lookup-v1".to_owned(), + values: Some(BTreeMap::from([ + ( + "record_reference".to_owned(), + SelectorValue::from("synthetic-record-001"), + ), + ("sequence".to_owned(), SelectorValue::from(7_i64)), + ("confirmed".to_owned(), SelectorValue::from(true)), + ])), + }, + }], + } + } + + /// The wire form is a frozen contract, so this is a golden serialization, + /// including member names, member order, and the omission of `holderKey`. + #[test] + fn the_request_body_serializes_to_the_frozen_wire_form() { + assert_eq!( + serde_json::to_string(&body()).expect("the request body serializes"), + concat!( + r#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","#, + r#""requirement":"urn:example:client:requirement:status:v1","#, + r#""purpose":"example-decision","#, + r#""subjects":[{"role":"subject","selector":{"profile":"record-lookup-v1","#, + r#""values":{"confirmed":true,"record_reference":"synthetic-record-001","sequence":7}}}]}"#, + ) + ); + } + + #[test] + fn a_selector_without_request_values_omits_the_member() { + let mut body = body(); + body.subjects[0].selector.values = None; + let rendered = serde_json::to_string(&body).expect("the request body serializes"); + assert!(!rendered.contains("values"), "{rendered}"); + } + + #[test] + fn debug_output_never_carries_selector_values() { + let rendered = format!("{:?}", body()); + assert!(!rendered.contains("synthetic-record-001"), "{rendered}"); + assert!(!rendered.contains("record-lookup-v1"), "{rendered}"); + } +} diff --git a/crates/registry-evidence-client/src/token.rs b/crates/registry-evidence-client/src/token.rs new file mode 100644 index 000000000..48a4a5c9e --- /dev/null +++ b/crates/registry-evidence-client/src/token.rs @@ -0,0 +1,150 @@ +//! Bearer credential acquisition for the Evidence request. +//! +//! A token never reaches a log line, an error, a `Debug` rendering, or a +//! snapshot. It is held in a wrapper that wipes its buffer on drop and is +//! exposed only where the outbound request header is built. + +use std::fmt; + +use async_trait::async_trait; +use thiserror::Error; +use zeroize::Zeroizing; + +/// Longest accepted credential. Access tokens are bounded well below this; the +/// limit keeps a hostile provider from handing over an unbounded header. +const MAXIMUM_TOKEN_BYTES: usize = 8 * 1024; + +/// One bearer credential for one outbound request. +pub struct BearerToken(Zeroizing); + +impl BearerToken { + /// Accept a credential that can be placed in an `Authorization` header + /// without escaping or folding. + /// + /// The rejection carries no part of the value, so an invalid credential + /// cannot reach a diagnostic through the error path. + pub fn new(value: impl Into) -> Result { + let value = Zeroizing::new(value.into()); + if value.is_empty() || value.len() > MAXIMUM_TOKEN_BYTES { + return Err(TokenError::Invalid { + reason: "a bearer credential must be non-empty and within the accepted length", + }); + } + // Visible ASCII only. This is the header-safe subset, so no credential + // can inject a carriage return, a newline, or a byte the header + // encoder would have to escape. + if !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(TokenError::Invalid { + reason: "a bearer credential must contain only visible ASCII characters", + }); + } + Ok(Self(value)) + } + + /// The credential text, for building exactly one outbound header. + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl Clone for BearerToken { + fn clone(&self) -> Self { + Self(Zeroizing::new(self.0.as_str().to_owned())) + } +} + +impl fmt::Debug for BearerToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BearerToken") + .finish_non_exhaustive() + } +} + +/// Source of the bearer credential the client presents. +/// +/// Implementations may cache, refresh, or mint a credential. The client calls +/// this once per outbound request and never stores what it returns. +#[async_trait] +pub trait TokenProvider: Send + Sync { + async fn bearer_token(&self) -> Result; +} + +/// A credential the integrator already holds. +/// +/// This is the deployment where an operator, a supervisor, or an outer service +/// supplies the access token. Renewal is that caller's responsibility. +#[derive(Debug, Clone)] +pub struct StaticToken(BearerToken); + +impl StaticToken { + pub fn new(value: impl Into) -> Result { + Ok(Self(BearerToken::new(value)?)) + } +} + +#[async_trait] +impl TokenProvider for StaticToken { + async fn bearer_token(&self) -> Result { + Ok(self.0.clone()) + } +} + +/// Why a credential could not be supplied. +/// +/// Every message is fixed text. A provider must not place a credential, a +/// response body, or a header value in this error. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TokenError { + #[error("the token provider could not supply a bearer credential")] + Unavailable, + #[error("the bearer credential is not usable: {reason}")] + Invalid { reason: &'static str }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_static_provider_returns_the_configured_credential() { + let provider = StaticToken::new("header-safe-token").expect("the credential is accepted"); + let token = provider + .bearer_token() + .await + .expect("a static provider always succeeds"); + assert_eq!(token.expose(), "header-safe-token"); + } + + #[test] + fn unusable_credentials_are_refused_without_echoing_them() { + for candidate in [ + "", + "canary space", + "canary\ttab", + "canary\rcarriage-return", + "canary\nnewline", + "canary\u{00e9}-non-ascii", + ] { + let error = BearerToken::new(candidate).expect_err("the credential is refused"); + let rendered = error.to_string(); + assert!( + !rendered.contains("canary"), + "the error rendered part of the credential: {rendered}" + ); + } + assert!(BearerToken::new("A".repeat(MAXIMUM_TOKEN_BYTES + 1)).is_err()); + } + + #[test] + fn debug_output_never_carries_the_credential() { + let token = BearerToken::new("secret-canary-value").expect("the credential is accepted"); + let rendered = format!("{token:?}"); + assert!(!rendered.contains("secret-canary-value"), "{rendered}"); + + let provider = StaticToken::new("secret-canary-value").expect("the credential is accepted"); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains("secret-canary-value"), "{rendered}"); + } +} diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs new file mode 100644 index 000000000..6d000c9e3 --- /dev/null +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -0,0 +1,974 @@ +#![cfg(unix)] + +//! Proof that this client and a real Evidence deployment agree. +//! +//! Every case here drives the actual runtime over loopback HTTP: the real +//! bundle loader, the real authenticator, the real authorization and selector +//! path, the real signer, and the real problem contract. The client's own +//! request types, discovery types, and problem parser are asserted against that +//! runtime rather than against a stub of this crate's making, so a disagreement +//! about a member name, a media type, a status code, or a policy field fails +//! here. +//! +//! The deployment is the tracked synthetic acceptance fixture, rewritten for the +//! local assurance profile. An externally driven runtime has to use the public +//! `initialize` seam, which builds the deployed authenticator and therefore +//! requires a loopback token issuer, which only the local profile permits. +//! Nothing in the fixture names a source product. + +use std::{ + error::Error, + fs, + net::TcpListener, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use registry_evidence::{runtime::EvidenceRuntime, server}; +use registry_evidence_client::{ + AssuranceProfile, ConceptForm, DefinitionCardinality, DefinitionKind, EvidenceClient, + EvidenceClientConfig, EvidenceClientError, EvidenceDefinitionsDocument, EvidenceRequestSpec, + PublicValue, SelectorField, SelectorValue, SelectorValueOrigin, StaticToken, + SubjectExpectations, SubjectRequest, TransportKind, VerificationError, + EVIDENCE_DEFINITIONS_SCHEMA_V1, +}; +use registry_platform_crypto::{sign, PrivateJwk}; +use serde_json::{json, Value}; +use url::Url; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, +}; + +/// Vocabulary the tracked acceptance fixture publishes. +const TOKEN_AUDIENCE: &str = "evidence-fixture"; +const CONFIGURED_TAG: &str = "fixture-agency"; +const REQUIREMENT: &str = "urn:example:fixture:requirement:adult-status:v1"; +const SIGNING_KEY_ID: &str = "fixture-key-2026-01"; + +/// Vocabulary this suite chooses. +const RELYING_AUDIENCE: &str = "https://relying.invalid/procedure"; +const PRINCIPAL: &str = "client-suite-principal"; +const AUTH_KEY_ID: &str = "client-suite-auth-key"; +const SOURCE_BEARER: &str = "source-bearer-canary"; + +/// Prefix the runtime gives every published subject binding. +const BINDING_PREFIX: &str = "urn:evidence:subject:v1_"; + +/// The requirement states a validity of exactly one day. The verifier refuses an +/// assertion whose lifetime exceeds the policy's own maximum, and the comparison +/// is strict, so the exact configured validity is an accepted policy. +const MAXIMUM_ASSERTION_LIFETIME_SECONDS: u64 = 86_400; +const CLOCK_SKEW_SECONDS: u64 = 30; + +/// A source answer that resolves to exactly one record. +fn resolved_source_answer() -> Value { + json!({"total": 1, "date_of_birth": "2000-01-01"}) +} + +/// A source answer that resolves to no record. The public contract collapses +/// this with ambiguity and with a missing fact into one unavailable answer. +fn unresolved_source_answer() -> Value { + json!({"total": 0}) +} + +// --------------------------------------------------------------------------- +// Cases +// --------------------------------------------------------------------------- + +/// The first exchange has no binding to pin, so it adopts the one the response +/// carries; the second pins what the first exposed. This is the whole documented +/// workflow, against a deployment that computes bindings with a secret the +/// relying party never holds. +#[tokio::test] +async fn first_use_acceptance_then_pinning_completes_two_verified_exchanges() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let first = client + .prepare(spec( + &definitions, + "first-use", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + let accepted = client.request_and_verify(&first).await?; + + let pinned = accepted.pinned_subject_expectations(); + let second = client + .prepare(spec( + &definitions, + "first-use", + SubjectExpectations::Pinned(pinned.clone()), + )) + .unwrap(); + let repinned = client.request_and_verify(&second).await?; + Ok(( + accepted, + pinned, + repinned, + first.request_nonce().to_owned(), + second.request_nonce().to_owned(), + )) + } + .await; + let (accepted, pinned, repinned, first_nonce, second_nonce) = + proof.expect("the deployment answers and the response verifies"); + + let evidence = accepted.evidence(); + assert_eq!(evidence.assurance_profile, AssuranceProfile::Local); + assert_eq!(evidence.supports_requirement, REQUIREMENT); + assert_eq!(evidence.audience, RELYING_AUDIENCE); + assert_eq!(evidence.request_nonce, first_nonce); + assert_eq!(evidence.subjects.len(), 1); + assert_eq!(evidence.subjects[0].role, "subject"); + assert!( + evidence.subjects[0].binding.starts_with(BINDING_PREFIX), + "the published binding uses the versioned prefix" + ); + assert_eq!(evidence.supported_values.len(), 1); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + ); + assert!( + accepted + .operation() + .is_some_and(|operation| operation.bytes().all(|byte| byte.is_ascii_alphanumeric())), + "the exchange carries an opaque correlation identifier" + ); + + // Persisting the accepted bindings and pinning them is what turns the next + // answer about another subject into a verification failure. + assert_eq!(pinned.len(), 1); + assert_eq!(pinned[0].role, "subject"); + assert_eq!(pinned[0].binding, evidence.subjects[0].binding); + assert_eq!(repinned.evidence().request_nonce, second_nonce); + assert_eq!( + repinned.evidence().subjects[0].binding, + evidence.subjects[0].binding, + "the same subject keeps the same binding across exchanges" + ); + assert_ne!( + first_nonce, second_nonce, + "each prepared request is its own" + ); +} + +/// The property pinning exists for: once a binding is pinned, an assertion about +/// another subject is refused even though it is correctly signed, correctly +/// scoped, and answers this exact request. +#[tokio::test] +async fn a_pinned_binding_refuses_an_assertion_about_another_subject() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let known = client + .prepare(spec( + &definitions, + "known-subject", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + let known = client.request_and_verify(&known).await?; + + // The same request shape, a different subject, and the first subject's + // binding pinned. + let other = client + .prepare(spec( + &definitions, + "other-subject", + SubjectExpectations::Pinned(known.pinned_subject_expectations()), + )) + .unwrap(); + Ok(( + known.pinned_subject_expectations(), + client.request_and_verify(&other).await, + )) + } + .await; + let (pinned, refusal) = proof.expect("both exchanges reach the deployment"); + + assert_eq!( + refusal.expect_err("the pinned expectation refuses the other subject"), + EvidenceClientError::Verification(VerificationError::Policy), + ); + assert!(pinned[0].binding.starts_with(BINDING_PREFIX)); +} + +/// Discovery is authoring input, and these are the client's own closed types +/// reading it. A runtime that renamed a member, changed a case convention, or +/// published a shape outside the closed set would fail to deserialize. +#[tokio::test] +async fn discovery_publishes_shapes_this_client_parses_exactly() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + let definitions = client + .discover() + .await + .expect("the deployment publishes the requester's definitions"); + + assert_eq!(definitions.schema, EVIDENCE_DEFINITIONS_SCHEMA_V1); + assert_eq!(definitions.assurance_profile, AssuranceProfile::Local); + assert!(definitions.configuration_revision.starts_with("sha256:")); + assert_eq!(definitions.definitions.len(), 1); + + let definition = definitions + .definition(REQUIREMENT) + .expect("the requester is entitled to the fixture requirement"); + assert_eq!(definition.kind, DefinitionKind::Criterion); + assert_eq!(definition.purpose, "fixture-eligibility"); + assert_eq!(definition.subjects.len(), 1); + assert_eq!( + definition.subjects[0].cardinality, + DefinitionCardinality::One + ); + assert_eq!( + definition.subjects[0].selector.value_origin, + SelectorValueOrigin::Request + ); + assert!( + !definition.subjects[0].selector.fields.is_empty(), + "a request-origin selector publishes its fields" + ); + assert_eq!(definition.concepts.len(), 1); + assert_eq!(definition.concepts[0].form, ConceptForm::Boolean); + assert!( + definition.concepts[0].scalar_expected_output().is_some(), + "a boolean concept yields a scalar expectation" + ); +} + +/// The published key set is discovery, not a trust anchor: this proves the +/// fetched document is the deployment's own, which is what makes an out-of-band +/// review of it meaningful. Verification still uses only the pinned set. +#[tokio::test] +async fn the_published_key_set_is_the_deployments_own() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + let published = client + .fetch_jwks() + .await + .expect("the deployment publishes its verification keys"); + + assert_eq!(&published, deployment.runtime.jwks()); + assert_eq!(published.keys.len(), 1); + assert_eq!(published.keys[0]["kid"], json!(SIGNING_KEY_ID)); + assert_eq!( + published.keys[0].get("d"), + None, + "the published key set carries no private material" + ); +} + +/// A credential the issuer did not sign is refused, and the refusal names only +/// the closed public code. +#[tokio::test] +async fn a_tampered_credential_is_refused_without_detail() { + let deployment = start(resolved_source_answer()).await; + let mut tampered = deployment.token(); + let last = tampered.pop().expect("the credential has a signature"); + tampered.push(if last == 'A' { 'B' } else { 'A' }); + let client = deployment.client(&tampered); + + let error = client + .discover() + .await + .expect_err("a credential that fails signature verification is refused"); + let EvidenceClientError::Denied { + status, + code, + operation, + retry_after_seconds, + } = error + else { + panic!("the refusal maps onto the denied failure"); + }; + assert_eq!(status, 401); + assert_eq!(code, "authentication_failed"); + assert!(operation.is_some_and(|operation| !operation.is_empty())); + assert_eq!(retry_after_seconds, None); +} + +/// A valid credential the deployment does not entitle is refused at the request +/// endpoint. Discovery answers it, with nothing in it. +#[tokio::test] +async fn a_credential_without_the_configured_tag_is_refused() { + let deployment = start(resolved_source_answer()).await; + let entitled = deployment.client(&deployment.token()); + let unentitled = deployment.client(&deployment.token_with_tags(&["other-agency"])); + + let proof: Result<_, Box> = async { + let definitions = entitled.discover().await?; + let visible = unentitled.discover().await?; + let prepared = unentitled + .prepare(spec( + &definitions, + "unentitled", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + Ok((visible, unentitled.request_and_verify(&prepared).await)) + } + .await; + let (visible, refusal) = proof.expect("both callers authenticate"); + + assert!( + visible.definitions.is_empty(), + "discovery lists only what the caller may invoke" + ); + let EvidenceClientError::Denied { status, code, .. } = + refusal.expect_err("an unentitled caller cannot request evidence") + else { + panic!("the refusal maps onto the denied failure"); + }; + assert_eq!(status, 403); + assert_eq!(code, "not_authorized"); +} + +/// The unavailable answer is its own failure, distinct from a refusal, and it +/// must not be read as a statement about the subject. +#[tokio::test] +async fn a_request_the_deployment_cannot_answer_reports_no_evidence() { + let deployment = start(unresolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let prepared = client + .prepare(spec( + &definitions, + "unresolved", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + Ok(client.request_and_verify(&prepared).await) + } + .await; + let refusal = proof.expect("the deployment answers"); + + let EvidenceClientError::NotAvailable { operation } = + refusal.expect_err("an unresolved request produces no evidence") + else { + panic!("the unavailable answer maps onto its own failure"); + }; + assert!(operation.is_some_and(|operation| !operation.is_empty())); +} + +/// A nonce identifies exactly one request. A real signed response that verifies +/// against its own prepared request must not verify against another one, which +/// is why a retry has to be a fresh `prepare` rather than a resend. +#[tokio::test] +async fn a_response_cannot_verify_against_another_prepared_request() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let sent = client + .prepare(spec( + &definitions, + "nonce-check", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + let other = client + .prepare(spec( + &definitions, + "nonce-check", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + let response = client.send(&sent).await?; + Ok(( + client.verify(&sent, &response), + client.verify(&other, &response), + )) + } + .await; + let (own, foreign) = proof.expect("the deployment answers the request that was sent"); + + own.expect("the response verifies against the request it answered"); + assert_eq!( + foreign.expect_err("the response cannot answer a request it never saw"), + EvidenceClientError::Verification(VerificationError::Policy), + ); +} + +/// The same bytes that verify from the deployment are refused when they arrive +/// under a media type the response contract does not use. The real runtime +/// cannot emit that, so the replay comes from a stub; the bytes are the +/// runtime's own. +#[tokio::test] +async fn a_response_under_the_wrong_media_type_is_refused() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let prepared = client + .prepare(spec( + &definitions, + "media-type", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + let response = client.send(&prepared).await?; + client.verify(&prepared, &response)?; + + let replay = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(response.body().to_vec(), "application/json"), + ) + .mount(&replay) + .await; + let replayed = EvidenceClient::new(EvidenceClientConfig::new( + Url::parse(&replay.uri())?, + Arc::new(StaticToken::new(deployment.token())?), + deployment.runtime.jwks().clone(), + )) + .unwrap(); + // Held so the stub outlives the exchange it answers. + let refusal = replayed.send(&prepared).await; + drop(replay); + Ok(refusal) + } + .await; + let refusal = proof.expect("the deployment answers and the stub replays"); + + let EvidenceClientError::Protocol { status, code, .. } = + refusal.expect_err("a response outside the contract's media type is refused") + else { + panic!("a contract violation maps onto the protocol failure"); + }; + assert_eq!(status, 200); + assert_eq!(code, None); +} + +/// The response bound is the relying party's, enforced before the body is +/// parsed. +#[tokio::test] +async fn a_response_beyond_the_configured_bound_is_refused() { + let deployment = start(resolved_source_answer()).await; + let client = deployment.client(&deployment.token()); + let bounded = deployment.bounded_client(&deployment.token(), 64); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let prepared = bounded + .prepare(spec( + &definitions, + "bounded", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); + Ok(bounded.send(&prepared).await) + } + .await; + let refusal = proof.expect("the deployment answers"); + + assert_eq!( + refusal.expect_err("a response past the bound is refused"), + EvidenceClientError::Transport { + kind: TransportKind::ResponseTooLarge + }, + ); +} + +// --------------------------------------------------------------------------- +// Request specifications +// --------------------------------------------------------------------------- + +/// Build a request specification from the deployment's own definitions +/// document. +/// +/// Everything except the audience and the subject expectations comes from the +/// published definition, which is what a relying party reads once while +/// authoring its procedure. The audience is the relying party's own registered +/// identifier, which the deployment takes from the authenticated caller. +fn spec( + definitions: &EvidenceDefinitionsDocument, + subject_label: &str, + subject_expectations: SubjectExpectations, +) -> EvidenceRequestSpec { + let definition = definitions + .definition(REQUIREMENT) + .expect("the requester is entitled to the fixture requirement"); + EvidenceRequestSpec { + requirement: definition.requirement.clone(), + purpose: definition.purpose.clone(), + audience: RELYING_AUDIENCE.to_owned(), + evidence_type: definition.evidence_type.clone(), + issued_by: definitions.issued_by.clone(), + provided_by: definitions.provided_by.clone(), + configuration_revision: definitions.configuration_revision.clone(), + expected_assurance_profile: definitions.assurance_profile, + subjects: definition + .subjects + .iter() + .map(|subject| SubjectRequest { + role: subject.role.clone(), + selector_profile: subject.selector.profile.clone(), + selector_values: Some( + subject + .selector + .fields + .iter() + .map(|field| { + ( + field.name().to_owned(), + selector_value(field, subject_label), + ) + }) + .collect(), + ), + }) + .collect(), + expected_outputs: definition + .concepts + .iter() + .map(|concept| { + concept + .scalar_expected_output() + .expect("the fixture publishes one scalar concept") + }) + .collect(), + maximum_assertion_lifetime_seconds: MAXIMUM_ASSERTION_LIFETIME_SECONDS, + clock_skew_seconds: CLOCK_SKEW_SECONDS, + subject_expectations, + } +} + +/// A synthetic value for one published selector field. +/// +/// Selector values are the relying party's own lookup input. The fixed source in +/// this suite answers on method and path alone, so only the published field +/// metadata constrains them, and two different labels name two different +/// subjects to the deployment. +fn selector_value(field: &SelectorField, subject_label: &str) -> SelectorValue { + match field { + SelectorField::String { + name, + minimum_bytes, + maximum_bytes, + } => { + let value = format!("synthetic-{subject_label}"); + let length = u64::try_from(value.len()).expect("the value length fits"); + assert!( + (*minimum_bytes..=*maximum_bytes).contains(&length), + "the synthetic value for {name} is within the published bounds" + ); + SelectorValue::from(value) + } + SelectorField::Date { .. } => SelectorValue::from("2000-01-01"), + SelectorField::Integer { minimum, .. } => SelectorValue::from(*minimum), + SelectorField::Boolean { .. } => SelectorValue::from(true), + SelectorField::ControlledCode { name, .. } => { + panic!("the fixture selector profile publishes no controlled code field: {name}") + } + } +} + +// --------------------------------------------------------------------------- +// The deployment harness +// --------------------------------------------------------------------------- + +/// One real Evidence deployment, serving on loopback for the life of one test. +struct Deployment { + /// Both the token issuer's key source and the deployment's fixed source. + /// One server keeps the issuer on the canonical loopback origin the local + /// assurance profile requires, and its uri is that origin. + _source: MockServer, + issuer: String, + auth_key: PrivateJwk, + base_url: Url, + runtime: Arc, + bundle_root: PathBuf, + runtime_path: PathBuf, + shutdown: Option>, + server: tokio::task::JoinHandle>, + /// Held so the deployment on disk outlives the runtime that reads it. + _directory: tempfile::TempDir, +} + +impl Deployment { + /// A client pinned to this deployment's published verification keys. + /// + /// Pinning the runtime's own key set is the out-of-band review this suite + /// stands in for: the keys are taken from the runtime object, not from the + /// response being verified. + fn client(&self, access_token: &str) -> EvidenceClient { + self.build_client(access_token, None) + } + + /// The same client under a smaller response bound. + fn bounded_client(&self, access_token: &str, max_response_bytes: u64) -> EvidenceClient { + self.build_client(access_token, Some(max_response_bytes)) + } + + fn build_client(&self, access_token: &str, max_response_bytes: Option) -> EvidenceClient { + let mut config = EvidenceClientConfig::new( + self.base_url.clone(), + Arc::new(StaticToken::new(access_token).expect("the credential is header-safe")), + self.runtime.jwks().clone(), + ); + if let Some(max_response_bytes) = max_response_bytes { + config = config.with_max_response_bytes(max_response_bytes); + } + EvidenceClient::new(config).expect("the client configuration is usable") + } + + /// A credential the deployment entitles. + fn token(&self) -> String { + self.token_with_tags(&[CONFIGURED_TAG]) + } + + /// A credential this issuer signed, carrying the requester tags given. + fn token_with_tags(&self, requester_tags: &[&str]) -> String { + let now = Utc::now().timestamp(); + let claims = json!({ + "iss": self.issuer, + "aud": TOKEN_AUDIENCE, + "sub": PRINCIPAL, + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": requester_tags, + "evidence_audience": RELYING_AUDIENCE, + }); + let header = json!({"alg": "EdDSA", "kid": AUTH_KEY_ID, "typ": "at+jwt"}); + let signing_input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("the header serializes")), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("the claims serialize")), + ); + let signature = sign(signing_input.as_bytes(), &self.auth_key).expect("the issuer signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) + } +} + +impl Drop for Deployment { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + // A drop cannot await the graceful stop, so the task is abandoned + // rather than joined. The temporary deployment it reads is removed + // below, and the process ends with the test binary. + self.server.abort(); + // The runtime requires an immutable deployment, so the staged tree was + // sealed. Restoring write permission is what lets the temporary + // directory be removed; a failure here leaves a directory behind for + // the test host to reclaim, and a drop has nowhere to report it. + let _ = fs::set_permissions(&self.runtime_path, fs::Permissions::from_mode(0o644)); + unseal(&self.bundle_root); + } +} + +/// Stage, seal, load, and serve one deployment whose fixed source answers with +/// `source_answer`. +async fn start(source_answer: Value) -> Deployment { + let source = MockServer::start().await; + let issuer = source.uri(); + let auth_key = generate_key(AUTH_KEY_ID); + + // The issuer's key set and the fixed source share this origin. Under the + // local assurance profile the authentication issuer must be a canonical + // loopback HTTP origin, which is exactly what a wiremock server publishes. + Mock::given(method("GET")) + .and(path("/.well-known/jwks.json")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"keys": [auth_key.public()]})), + ) + .mount(&source) + .await; + // Matched on method and path only. The request the adapter builds is the + // runtime's own contract, proven by the runtime's suite; re-pinning it here + // would test the fixture rather than this client. + Mock::given(method("POST")) + .and(path("/v1/facts")) + .respond_with(ResponseTemplate::new(200).set_body_json(source_answer)) + .mount(&source) + .await; + + // Hold the allocation while the matching deployment is authored, and + // release it only immediately before the service binds it. + let reservation = TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); + let port = reservation + .local_addr() + .expect("read the reserved address") + .port(); + + let directory = tempfile::tempdir().expect("temporary deployment root"); + let bundle_root = directory.path().join("bundle"); + let secret_root = directory.path().join("secrets"); + let runtime_path = directory.path().join("runtime.yaml"); + let audit_path = directory.path().join("audit.jsonl"); + fs::create_dir(&bundle_root).expect("create the bundle root"); + fs::create_dir(&secret_root).expect("create the secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("the secret root is owner-only"); + copy_tree(&fixture_root(), &bundle_root); + rewrite_for_local_profile(&bundle_root, &issuer); + + write_secret( + &secret_root, + "audit-hash-key", + "audit-hash-secret-canary-32-bytes-minimum", + ); + write_secret( + &secret_root, + "subject-binding-key", + "subject-binding-secret-canary-32-bytes-minimum", + ); + write_secret(&secret_root, "signing-key", &private_jwk(SIGNING_KEY_ID)); + write_secret(&secret_root, "source-a-token", SOURCE_BEARER); + fs::write( + &runtime_path, + runtime_document(port, &bundle_root, &secret_root, &audit_path), + ) + .expect("write the runtime configuration"); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("the runtime configuration is immutable"); + seal(&bundle_root); + + let runtime = Arc::new( + EvidenceRuntime::initialize(&runtime_path) + .await + .expect("the staged local deployment initializes"), + ); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let served = Arc::clone(&runtime); + drop(reservation); + let server = tokio::spawn(async move { + server::serve(served, async { + let _ = shutdown_rx.await; + }) + .await + }); + + let deployment = Deployment { + _source: source, + issuer, + auth_key, + base_url: Url::parse(&format!("http://127.0.0.1:{port}")).expect("the base URL parses"), + runtime, + bundle_root, + runtime_path, + shutdown: Some(shutdown_tx), + server, + _directory: directory, + }; + await_readiness(&deployment).await; + deployment +} + +/// Wait until the deployment reports itself ready, or fail with the reason it +/// did not. +async fn await_readiness(deployment: &Deployment) { + let probe = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(1)) + .build() + .expect("the readiness probe client builds"); + let ready = deployment + .base_url + .join("ready") + .expect("the readiness URL resolves"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + assert!( + !deployment.server.is_finished(), + "the deployment stopped before it reported readiness" + ); + if probe + .get(ready.clone()) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the deployment reports readiness"); +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/adult-status") +} + +/// Point the staged bundle at this test's issuer and source, and lower it to the +/// local assurance profile. +/// +/// The local profile is what permits a loopback token issuer. Every other +/// security decision in the bundle, including authentication, authorization, +/// selector validation, subject binding, signing, and audit, is unchanged. +fn rewrite_for_local_profile(bundle_root: &Path, origin: &str) { + let configuration_path = bundle_root.join("evidence.yaml"); + let mut document = + fs::read_to_string(&configuration_path).expect("the staged configuration is readable"); + replace_exact( + &mut document, + "assuranceProfile: evidence-grade", + "assuranceProfile: local", + 1, + ); + replace_exact( + &mut document, + "baseUrl: https://source.invalid", + &format!("baseUrl: {origin}"), + 1, + ); + replace_exact( + &mut document, + "issuer: https://identity.invalid", + &format!("issuer: {origin}"), + 1, + ); + replace_exact( + &mut document, + "jwksUri: https://identity.invalid/.well-known/jwks.json", + &format!("jwksUri: {origin}/.well-known/jwks.json"), + 1, + ); + fs::write(&configuration_path, document).expect("the local configuration is written"); +} + +fn runtime_document( + port: u16, + bundle_root: &Path, + secret_root: &Path, + audit_path: &Path, +) -> String { + format!( + r#"version: 1 +bundleDirectory: {bundle} +listener: + bindHost: 127.0.0.1 + port: {port} + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: {secrets} +auditStorage: + path: {audit} + maximumFileBytes: 10485760 +outboundTls: + systemRoots: true + trustProfiles: {{}} +"#, + bundle = bundle_root.display(), + secrets = secret_root.display(), + audit = audit_path.display(), + ) +} + +/// A fresh Ed25519 private JWK under the identifier the reader expects. +fn private_jwk(key_id: &str) -> String { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("the test host supplies randomness"); + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": key_id, + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + }) + .to_string() +} + +fn generate_key(key_id: &str) -> PrivateJwk { + PrivateJwk::parse(&private_jwk(key_id)).expect("the generated key parses") +} + +fn replace_exact(document: &mut String, from: &str, to: &str, expected: usize) { + assert_eq!( + document.matches(from).count(), + expected, + "fixture drift for {from}" + ); + *document = document.replace(from, to); +} + +fn write_secret(secret_root: &Path, name: &str, value: &str) { + let path = secret_root.join(name); + fs::write(&path, value.as_bytes()).expect("write the synthetic secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("the synthetic secret is owner-only"); +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("the tracked fixture is readable") { + let entry = entry.expect("the fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("the fixture file type is readable") + .is_dir() + { + fs::create_dir(&destination).expect("copy the fixture directory"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("copy the fixture file"); + } + } +} + +/// Make the staged bundle immutable, as the runtime requires. +fn seal(root: &Path) { + for entry in fs::read_dir(root).expect("the staged bundle is readable") { + let entry = entry.expect("the staged entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("the staged file type is readable") + .is_dir() + { + seal(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("the bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("the bundle file is immutable"); + } + } + fs::set_permissions(root, fs::Permissions::from_mode(0o555)) + .expect("the bundle root is immutable"); +} + +/// Restore write permission so the temporary directory can be removed. +fn unseal(root: &Path) { + let _ = fs::set_permissions(root, fs::Permissions::from_mode(0o755)); + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let child = entry.path(); + if child.is_dir() { + unseal(&child); + } else { + let _ = fs::set_permissions(&child, fs::Permissions::from_mode(0o644)); + } + } +} diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index 79023abc8..7d35ed846 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -31,6 +31,13 @@ verification so client tooling can verify a signed response without the runtime. The verifier library sits beside the runtime and is not a second runtime; its source is covered by the same source-product and domain neutrality checks. +`registry-evidence-client` is adopter tooling beside the runtime, like +`registry-evidencectl`. It requests assertions over the public HTTP contract and +links `registry-evidence-verifier` for every verification decision, so it +re-implements no part of evaluation, signing, or verification. It sits outside +the frozen Version 1 runtime contract, and its source is covered by the same +source-product and domain neutrality checks. + Selected `registry-platform-*` primitives may be reused only when their existing contracts fit Evidence directly. The approved candidates are audit, crypto, OIDC, HTTP security, testing, and the `registry-platform-sdjwt` serialization diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index cffbb563d..ba67c73ae 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -10,6 +10,7 @@ production_text="$temporary_root/production-rust.txt" for source_file in $( rg --files \ "$repository_root/crates/registry-evidence/src" \ + "$repository_root/crates/registry-evidence-client/src" \ "$repository_root/crates/registry-evidence-verifier/src" \ "$repository_root/crates/registry-evidencectl/src" \ -g '*.rs' | sort @@ -134,6 +135,7 @@ done if rg -n -i 'dhis2|opencrvs' \ "$production_text" \ "$repository_root/crates/registry-evidence/Cargo.toml" \ + "$repository_root/crates/registry-evidence-client/Cargo.toml" \ "$repository_root/crates/registry-evidence-verifier/Cargo.toml" \ "$repository_root/crates/registry-evidencectl/Cargo.toml" \ "$repository_root/Cargo.toml"; then From 6d6f33862c359a2cf0f1155c441095f770c52af2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 04:25:16 +0700 Subject: [PATCH 11/67] fix(evidence): tighten client preflight and problem mapping Loopback HTTP now covers the forms an adopter actually types, a prepared request enforces its one-send rule instead of only documenting it, a failure keeps the deployment's correlation identifier when the problem body is unreadable, selector integers get the same local pre-flight strings already had, and a bounded transient failure surfaces the wait the problem contract permits it. Security-sensitive review notes: - The single-send claim is taken before any I/O and is spent even by an attempt that fails on the wire, because the deployment may have answered a request whose answer the relying party never read. A resend would earn a second source access and a second audit entry there for one relying-party decision, and the request contract states the nonce is never uniqueness-checked. `PreparedEvidenceRequest` is no longer `Clone`: a clone would carry the same nonce with its own unclaimed flag, which is the reuse the flag exists to prevent. Verification stays unrestricted, being offline and idempotent. - The correlation identifier from the response header is held to the same bounded-alphanumeric rule as the body's own, so a hostile header value is dropped rather than copied into a relying party's records. The body's value still wins when it is readable and usable. - `format: uri` is deliberately not pre-flighted. The deployment asserts it, and a second opinion from a URL parser could disagree with a JSON Schema `uri` implementation in either direction. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/README.md | 11 +- crates/registry-evidence-client/src/client.rs | 101 ++++++++++++ crates/registry-evidence-client/src/config.rs | 34 +++- crates/registry-evidence-client/src/error.rs | 4 + crates/registry-evidence-client/src/lib.rs | 8 + .../registry-evidence-client/src/prepare.rs | 120 ++++++++++++-- .../registry-evidence-client/src/problem.rs | 146 ++++++++++++++++-- .../tests/against_a_real_deployment.rs | 12 +- 8 files changed, 403 insertions(+), 33 deletions(-) diff --git a/crates/registry-evidence-client/README.md b/crates/registry-evidence-client/README.md index 29afb8fda..d0659160d 100644 --- a/crates/registry-evidence-client/README.md +++ b/crates/registry-evidence-client/README.md @@ -56,9 +56,14 @@ async fn accept( uses the key set pinned at construction. Nothing here fetches keys at verification time, because a key set taken from the same origin as the response it would verify establishes nothing about that response. -- One prepared request is one exchange. Neither this crate nor its HTTP client - retries anything: a second attempt is a second `prepare` with a fresh nonce, - because a policy accepts exactly the answer to the request it was closed for. +- One prepared request is one exchange, enforced rather than advised. Neither this + crate nor its HTTP client retries anything, and a second `send` with the same + prepared request fails locally before any I/O: a second attempt is a second + `prepare` with a fresh nonce, because a policy accepts exactly the answer to the + request it was closed for, and a deployment never uniqueness-checks a nonce, so + a resend would earn a second source access and a second audit entry there. + Verifying is exempt: it is offline and idempotent, so a retained response may be + re-verified as often as the relying party likes. - Subject bindings are keyed values the deployment computes with a secret only it holds, so a relying party cannot derive the binding for a subject it has never seen. `SubjectExpectations::Pinned` is the only setting under which a verified diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index d446bdc86..7a14bc7ff 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -166,6 +166,7 @@ impl EvidenceClient { status: StatusCode::OK.as_u16(), code: None, operation: body.operation, + retry_after_seconds: None, }) } @@ -188,6 +189,7 @@ impl EvidenceClient { status: StatusCode::OK.as_u16(), code: None, operation: body.operation, + retry_after_seconds: None, }) } @@ -198,10 +200,17 @@ impl EvidenceClient { /// a second attempt has to be a second [`EvidenceClient::prepare`] with a /// fresh nonce. Retrying the same bytes would let a stale answer satisfy a /// policy that was closed for a different exchange. + /// + /// This is enforced, not merely advised: `prepared` allows exactly one send, + /// and a second call with the same prepared request returns a configuration + /// failure without reaching the deployment. The deployment never + /// uniqueness-checks a nonce, so a resend would earn a second source access + /// and a second audit entry there for one relying-party decision. pub async fn send( &self, prepared: &PreparedEvidenceRequest, ) -> Result { + prepared.claim_single_send()?; let url = self.endpoint(EVIDENCE_PATH)?; let body = serialize_request(prepared.body())?; let request = self @@ -217,6 +226,11 @@ impl EvidenceClient { /// Verify a signed response against the policy its request closed. /// /// The trusted key set is the one pinned at construction, always. + /// + /// Unlike sending, verifying is unrestricted. It is offline, idempotent, and + /// reaches no deployment, so a relying party may re-verify a retained + /// response against its retained prepared request as often as it likes, + /// including after the single send has been spent. pub fn verify( &self, prepared: &PreparedEvidenceRequest, @@ -226,6 +240,10 @@ impl EvidenceClient { } /// Request evidence and verify it, in one step. + /// + /// This spends the single send `prepared` allows, exactly as + /// [`EvidenceClient::send`] does, so calling it twice with one prepared + /// request fails locally on the second call. pub async fn request_and_verify( &self, prepared: &PreparedEvidenceRequest, @@ -353,6 +371,7 @@ impl EvidenceClient { media_type.as_deref(), &body, retry_after_seconds, + operation.as_deref(), )); } if status != StatusCode::OK.as_u16() @@ -362,6 +381,7 @@ impl EvidenceClient { status, code: None, operation, + retry_after_seconds: None, }); } Ok(RawEvidenceResponse { body, operation }) @@ -479,6 +499,7 @@ mod tests { AssuranceProfile, }; use std::sync::Arc; + use wiremock::{Mock, MockServer, ResponseTemplate}; fn client_for(base_url: &str, fixture: &SignedEvidenceFixture) -> EvidenceClient { EvidenceClient::new(EvidenceClientConfig::new( @@ -772,6 +793,86 @@ mod tests { } } + /// A prepared request is a single-use capability. The second send is refused + /// locally, so a deployment never sees one nonce twice and never repeats the + /// source access and audit entries a single request earns. + #[tokio::test] + async fn a_prepared_request_reaches_the_deployment_at_most_once() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = client_for(&server.uri(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/evidence")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + fixture.sign(prepared.request_nonce()), + EVIDENCE_JWS_MEDIA_TYPE, + )) + .expect(1) + .mount(&server) + .await; + + client + .send(&prepared) + .await + .expect("the first send happens"); + assert_eq!( + client + .send(&prepared) + .await + .expect_err("the second send is refused"), + EvidenceClientError::configuration( + "a prepared request may be sent once; prepare again for a fresh nonce" + ) + ); + assert_eq!( + server + .received_requests() + .await + .expect("the stub records what it received") + .len(), + 1, + "the refused send must not reach the deployment" + ); + } + + /// A body the problem contract does not cover leaves the client with nothing + /// to say about the failure, which is exactly when the deployment's own + /// identifier for the exchange matters. + #[tokio::test] + async fn a_failure_carries_the_correlation_identifier_even_with_an_unreadable_body() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = client_for(&server.uri(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/evidence")) + .respond_with( + ResponseTemplate::new(400) + .insert_header(CORRELATION_HEADER, "01JQ0QZ8YHZ0000000000000AB") + .set_body_raw(b"a gateway wrote this".to_vec(), "text/html"), + ) + .mount(&server) + .await; + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("a body outside the contract is a protocol failure"), + EvidenceClientError::Protocol { + status: 400, + code: None, + operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + retry_after_seconds: None, + } + ); + } + #[test] fn debug_output_never_carries_a_response_body_or_a_credential() { let fixture = signed_evidence(); diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index d992789ee..f4627162c 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -26,6 +26,10 @@ pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Time allowed for connection setup, including TLS negotiation. pub const DEFAULT_CONNECT_TIMEOUT: Duration = DEFAULT_OUTBOUND_CONNECT_TIMEOUT; +/// The one host name a cleartext base URL may carry. It is reserved for the +/// loopback interface, so a credential sent to it cannot leave the host. +const LOOPBACK_NAME: &str = "localhost"; + /// Everything the client needs, decided before the first request. pub struct EvidenceClientConfig { pub(crate) base_url: Url, @@ -124,18 +128,23 @@ impl EvidenceClientConfig { )); } // A bearer credential in cleartext is only acceptable when it cannot - // leave the host, which is the local development and tutorial case. + // leave the host, which is the local development and tutorial case. The + // accepted forms are the ones an adopter types: either loopback numeric + // family, or the reserved name `localhost`. Any other name is refused, + // because a name that happens to resolve to a loopback address is still + // resolved off-host, and the answer can change. let transport_protects_the_credential = match self.base_url.scheme() { "https" => true, - "http" => self - .base_url - .host() - .is_some_and(|host| matches!(host, url::Host::Ipv4(ip) if ip.is_loopback())), + "http" => self.base_url.host().is_some_and(|host| match host { + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + url::Host::Domain(name) => name == LOOPBACK_NAME, + }), _ => false, }; if !transport_protects_the_credential { return Err(EvidenceClientError::configuration( - "the base URL must use HTTPS, or HTTP with a numeric loopback host", + "the base URL must use HTTPS, or HTTP with a loopback host", )); } if self.max_response_bytes == 0 { @@ -180,6 +189,7 @@ mod tests { ) } + /// Every loopback form an adopter or a tutorial actually types. #[test] fn https_and_loopback_http_are_accepted() { for base_url in [ @@ -187,6 +197,11 @@ mod tests { "https://evidence.example.org/prefix/", "http://127.0.0.1:8080", "http://127.0.0.1:8080/", + "http://127.0.0.2:8080", + "http://[::1]:8080", + "http://[::1]:8080/prefix/", + "http://localhost:8080", + "http://localhost", ] { config(base_url) .validate() @@ -198,8 +213,13 @@ mod tests { fn a_base_url_that_cannot_protect_the_credential_is_refused() { for base_url in [ "http://evidence.example.org", - "http://localhost:8080", + "http://example.com", + "http://192.168.1.1:8080", + "http://[2001:db8::1]:8080", + // A name that resolves to a loopback address is still a name, and + // the credential would leave the host to be resolved. "http://127.0.0.2.nip.io:8080", + "http://localhost.evidence.example.org", "ftp://evidence.example.org", ] { assert!( diff --git a/crates/registry-evidence-client/src/error.rs b/crates/registry-evidence-client/src/error.rs index 83958a046..f85d2f4f9 100644 --- a/crates/registry-evidence-client/src/error.rs +++ b/crates/registry-evidence-client/src/error.rs @@ -57,6 +57,10 @@ pub enum EvidenceClientError { status: u16, code: Option, operation: Option, + /// Present only when the deployment reported a bounded transient + /// failure and asked for a wait. A relying party that honors it must + /// still prepare a fresh request before trying again. + retry_after_seconds: Option, }, /// Verification refused the response. The cause is the verifier's own diff --git a/crates/registry-evidence-client/src/lib.rs b/crates/registry-evidence-client/src/lib.rs index 1efa6ebcf..92493c641 100644 --- a/crates/registry-evidence-client/src/lib.rs +++ b/crates/registry-evidence-client/src/lib.rs @@ -57,6 +57,14 @@ //! answer to that request. Neither this crate nor its HTTP client retries //! anything: a second attempt is a second [`EvidenceClient::prepare`] with a //! fresh nonce. +//! +//! The rule is enforced rather than advised. A prepared request allows one send, +//! and a second [`EvidenceClient::send`] or +//! [`EvidenceClient::request_and_verify`] with it fails locally, before any I/O. +//! A deployment never uniqueness-checks a nonce, so a resend would earn a second +//! source access and a second audit entry there for one relying-party decision. +//! Verification is exempt: it is offline and idempotent, so a retained response +//! may be re-verified as often as the relying party likes. pub mod client; pub mod config; diff --git a/crates/registry-evidence-client/src/prepare.rs b/crates/registry-evidence-client/src/prepare.rs index abadbf707..a8c0cfcca 100644 --- a/crates/registry-evidence-client/src/prepare.rs +++ b/crates/registry-evidence-client/src/prepare.rs @@ -7,7 +7,10 @@ //! nonce, all before any byte leaves the process. Verification then compares the //! response against a policy the response could not have influenced. -use std::collections::BTreeSet; +use std::{ + collections::BTreeSet, + sync::atomic::{AtomicBool, Ordering}, +}; use registry_evidence_verifier::{ verifier::{ @@ -31,6 +34,12 @@ pub const MAXIMUM_SELECTOR_VALUES: usize = 16; pub const MAXIMUM_EXPECTED_OUTPUTS: usize = 16; const MAXIMUM_IDENTIFIER_BYTES: usize = 512; const MAXIMUM_SELECTOR_STRING_BYTES: usize = 512; +/// Smallest selector integer the request contract accepts. The bound is the +/// range a double represents exactly, so the value survives every JSON reader +/// between here and the source. +pub const MINIMUM_SELECTOR_INTEGER: i64 = -9_007_199_254_740_991; +/// Largest selector integer the request contract accepts. +pub const MAXIMUM_SELECTOR_INTEGER: i64 = 9_007_199_254_740_991; /// One requested subject, before the request body exists. #[derive(Debug, Clone)] @@ -118,16 +127,26 @@ impl std::fmt::Debug for SubjectExpectations { /// A request body and the closed policy that will judge its answer. /// -/// One prepared request is good for exactly one exchange. A nonce reused across -/// two requests would let an answer to the first satisfy the policy for the -/// second, so retrying means preparing again. -#[derive(Clone)] +/// One prepared request is good for exactly one exchange, and this type enforces +/// that: the first send attempt claims it, and a second is refused before any +/// I/O. A nonce reused across two requests would let an answer to the first +/// satisfy the policy for the second, and a deployment never uniqueness-checks +/// the nonce, so a resend would silently earn a second source access and a +/// second audit entry. Retrying means preparing again. +/// +/// Verifying is separate and unrestricted: it is offline and idempotent, so a +/// relying party may re-verify a retained response as often as it likes. +/// +/// This type is deliberately not `Clone`. A clone would carry the same nonce +/// with its own unclaimed flag, which is exactly the reuse the flag prevents. pub struct PreparedEvidenceRequest { body: EvidenceRequestBody, /// The policy with every expectation except the subject set, which /// `subject_expectations` decides. policy: EvidenceVerificationPolicyDocument, subject_expectations: SubjectExpectations, + /// Whether a send attempt has already claimed this request. + sent: AtomicBool, } impl PreparedEvidenceRequest { @@ -181,6 +200,7 @@ impl PreparedEvidenceRequest { body, policy, subject_expectations: spec.subject_expectations, + sent: AtomicBool::new(false), }) } @@ -208,6 +228,21 @@ impl PreparedEvidenceRequest { &self.body } + /// Claim the single send this prepared request is good for. + /// + /// The claim is taken before any I/O, and an attempt that fails on the wire + /// still spends it: the deployment may have answered the request even when + /// the relying party never read the answer, and resending the same nonce + /// would earn a second source access and a second audit entry there. + pub(crate) fn claim_single_send(&self) -> Result<(), EvidenceClientError> { + if self.sent.swap(true, Ordering::SeqCst) { + return Err(EvidenceClientError::configuration( + "a prepared request may be sent once; prepare again for a fresh nonce", + )); + } + Ok(()) + } + /// The same policy with an explicit subject set. This is how first-use /// acceptance reaches the ordinary verifier: the adopted bindings become /// stated expectations, and nothing else about the policy changes. @@ -258,6 +293,12 @@ impl std::fmt::Debug for PreparedEvidenceRequest { /// Refuse a specification the deployment would refuse, or one whose policy /// could not decide anything. fn validate(spec: &EvidenceRequestSpec) -> Result<(), EvidenceClientError> { + // Presence and length only. The contract also states `format: uri` for + // these identifiers, and the deployment asserts it, so restating it here + // would put a second opinion in front of the deciding one: a URL parser and + // a JSON Schema `uri` implementation can disagree in either direction, and + // either disagreement is a bug the adopter cannot work around. The + // deployment's answer stands. for identifier in [ &spec.requirement, &spec.audience, @@ -358,12 +399,22 @@ fn validate_selector_values( "each selector field name must match the request contract's lexical rule and appear once", )); } - if let SelectorValue::String(text) = value { - if text.is_empty() || text.len() > MAXIMUM_SELECTOR_STRING_BYTES { - return Err(EvidenceClientError::configuration( - "each selector string value must be present and bounded", - )); + match value { + SelectorValue::String(text) => { + if text.is_empty() || text.len() > MAXIMUM_SELECTOR_STRING_BYTES { + return Err(EvidenceClientError::configuration( + "each selector string value must be present and bounded", + )); + } + } + SelectorValue::Integer(number) => { + if !(MINIMUM_SELECTOR_INTEGER..=MAXIMUM_SELECTOR_INTEGER).contains(number) { + return Err(EvidenceClientError::configuration( + "each selector integer value must be within the range a double represents exactly", + )); + } } + SelectorValue::Boolean(_) => {} } } Ok(()) @@ -623,6 +674,24 @@ mod tests { )]); }), ), + ( + "a selector integer below the contract's minimum", + Box::new(|spec| { + spec.subjects[0].selector_values = Some(vec![( + "record_reference".to_owned(), + SelectorValue::from(MINIMUM_SELECTOR_INTEGER - 1), + )]); + }), + ), + ( + "a selector integer above the contract's maximum", + Box::new(|spec| { + spec.subjects[0].selector_values = Some(vec![( + "record_reference".to_owned(), + SelectorValue::from(MAXIMUM_SELECTOR_INTEGER + 1), + )]); + }), + ), ( "no expected output", Box::new(|spec| spec.expected_outputs.clear()), @@ -672,6 +741,37 @@ mod tests { } } + /// The contract's integer bounds are the ones a double can represent + /// exactly, and both extremes are inside them. + #[test] + fn a_selector_integer_at_the_contracts_bounds_is_accepted() { + for value in [MINIMUM_SELECTOR_INTEGER, 0, MAXIMUM_SELECTOR_INTEGER] { + let mut spec = spec(); + spec.subjects[0].selector_values = Some(vec![( + "record_reference".to_owned(), + SelectorValue::from(value), + )]); + PreparedEvidenceRequest::new(spec) + .unwrap_or_else(|error| panic!("{value} was refused: {error}")); + } + } + + #[test] + fn a_prepared_request_is_claimable_exactly_once() { + let prepared = PreparedEvidenceRequest::new(spec()).expect("the specification is accepted"); + prepared + .claim_single_send() + .expect("the first send may proceed"); + assert_eq!( + prepared + .claim_single_send() + .expect_err("the second send is refused"), + EvidenceClientError::configuration( + "a prepared request may be sent once; prepare again for a fresh nonce" + ) + ); + } + #[test] fn a_selector_whose_values_come_from_the_authenticated_caller_carries_none() { let mut spec = spec(); diff --git a/crates/registry-evidence-client/src/problem.rs b/crates/registry-evidence-client/src/problem.rs index 93c3ff01c..e122744a4 100644 --- a/crates/registry-evidence-client/src/problem.rs +++ b/crates/registry-evidence-client/src/problem.rs @@ -33,21 +33,31 @@ pub(crate) struct ProblemBody { /// Map a refused or failed exchange onto one coarse client failure. /// /// `retry_after_seconds` is read from the response header and honored only for -/// the rate-limited answer, which is the one case the contract permits it for. +/// the two answers the contract permits a bounded wait on: the rate-limited +/// refusal, and a transient dependency or service failure. +/// +/// `header_operation` is the identifier the response header carried. It is the +/// fallback for every failure that can name one, because the case where the body +/// cannot be read is exactly the case where the deployment's own identifier for +/// the exchange is all a relying party can take to support. A readable body's own +/// identifier wins when it satisfies the rule; both are held to the same rule. pub(crate) fn map_problem( status: u16, media_type: Option<&str>, body: &[u8], retry_after_seconds: Option, + header_operation: Option<&str>, ) -> EvidenceClientError { + let header_operation = header_operation.and_then(sanitized_operation); let Some(problem) = parse_problem(media_type, body) else { return EvidenceClientError::Protocol { status, code: None, - operation: None, + operation: header_operation, + retry_after_seconds: None, }; }; - let operation = sanitized_operation(&problem.operation); + let operation = sanitized_operation(&problem.operation).or(header_operation); match (status, problem.code.as_str()) { (401 | 403 | 429, code) => EvidenceClientError::Denied { status, @@ -60,6 +70,7 @@ pub(crate) fn map_problem( status, code: Some(code.to_owned()), operation, + retry_after_seconds: retry_after_seconds.filter(|_| status == 503), }, } } @@ -126,6 +137,8 @@ mod tests { .into_bytes() } + const OPERATION: &str = "01JQ0QZ8YHZ0000000000000AB"; + #[test] fn refusals_map_to_the_denied_failure() { for (status, code) in [ @@ -138,13 +151,14 @@ mod tests { Some(PROBLEM_MEDIA_TYPE), &problem_json(status, code), Some(1), + None, ); assert_eq!( mapped, EvidenceClientError::Denied { status, code: code.to_owned(), - operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + operation: Some(OPERATION.to_owned()), // Only the rate-limited answer may carry a wait. retry_after_seconds: (status == 429).then_some(1), } @@ -159,11 +173,12 @@ mod tests { Some(PROBLEM_MEDIA_TYPE), &problem_json(422, "evidence_not_available"), None, + None, ); assert_eq!( mapped, EvidenceClientError::NotAvailable { - operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + operation: Some(OPERATION.to_owned()), } ); } @@ -185,18 +200,119 @@ mod tests { Some(PROBLEM_MEDIA_TYPE), &problem_json(status, code), None, + None, + ); + assert_eq!( + mapped, + EvidenceClientError::Protocol { + status, + code: Some(code.to_owned()), + operation: Some(OPERATION.to_owned()), + retry_after_seconds: None, + } + ); + } + } + + /// The contract permits a bounded wait on a transient failure, which is the + /// answer a relying party can politely back off from. + #[test] + fn a_transient_failure_may_carry_a_bounded_wait() { + for (status, code, expected_wait) in [ + (503_u16, "dependency_unavailable", Some(30)), + (503, "service_unavailable", Some(30)), + // Nothing else in the coarse mapping surfaces a wait. + (400, "malformed_request", None), + (406, "response_format_not_acceptable", None), + (422, "malformed_request", None), + ] { + let mapped = map_problem( + status, + Some(PROBLEM_MEDIA_TYPE), + &problem_json(status, code), + Some(30), + None, ); assert_eq!( mapped, EvidenceClientError::Protocol { status, code: Some(code.to_owned()), - operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + operation: Some(OPERATION.to_owned()), + retry_after_seconds: expected_wait, } ); } } + /// The response header is the fallback identifier. The body's own value wins + /// whenever the body is readable, and both are held to the same rule. + #[test] + fn the_response_header_supplies_the_identifier_the_body_does_not() { + // An unreadable body, so the header is all there is. + assert_eq!( + map_problem(400, Some("text/html"), b"", None, Some(OPERATION)), + EvidenceClientError::Protocol { + status: 400, + code: None, + operation: Some(OPERATION.to_owned()), + retry_after_seconds: None, + } + ); + + // A readable body, whose own identifier is the one to quote. + assert_eq!( + map_problem( + 403, + Some(PROBLEM_MEDIA_TYPE), + &problem_json(403, "not_authorized"), + None, + Some("01HEADERONLY"), + ), + EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: Some(OPERATION.to_owned()), + retry_after_seconds: None, + } + ); + + // A readable body whose identifier is unusable falls back to the header. + let hostile = br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized","operation":"01AB\nrole=subject"}"#; + assert_eq!( + map_problem( + 403, + Some(PROBLEM_MEDIA_TYPE), + hostile, + None, + Some("01HEADERONLY"), + ), + EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: Some("01HEADERONLY".to_owned()), + retry_after_seconds: None, + } + ); + + // A header value outside the rule is dropped exactly like a body value. + assert_eq!( + map_problem( + 400, + Some("text/html"), + b"", + None, + Some("01AB role=subject"), + ), + EvidenceClientError::Protocol { + status: 400, + code: None, + operation: None, + retry_after_seconds: None, + } + ); + } + #[test] fn a_body_outside_the_closed_contract_is_never_read_as_a_refusal() { let unknown_member = br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized","operation":"01AB","hint":"subject not found"}"#; @@ -211,11 +327,12 @@ mod tests { b"".as_slice(), ] { assert_eq!( - map_problem(403, Some(PROBLEM_MEDIA_TYPE), body, None), + map_problem(403, Some(PROBLEM_MEDIA_TYPE), body, None, None), EvidenceClientError::Protocol { status: 403, code: None, operation: None, + retry_after_seconds: None, } ); } @@ -227,20 +344,23 @@ mod tests { 403, Some("application/json"), &problem_json(403, "not_authorized"), - None + None, + None, ), EvidenceClientError::Protocol { status: 403, code: None, operation: None, + retry_after_seconds: None, } ); assert_eq!( - map_problem(403, None, &problem_json(403, "not_authorized"), None), + map_problem(403, None, &problem_json(403, "not_authorized"), None, None), EvidenceClientError::Protocol { status: 403, code: None, operation: None, + retry_after_seconds: None, } ); } @@ -252,20 +372,21 @@ mod tests { "a".repeat(MAXIMUM_PROBLEM_BYTES) ); assert_eq!( - map_problem(403, Some(PROBLEM_MEDIA_TYPE), padded.as_bytes(), None), + map_problem(403, Some(PROBLEM_MEDIA_TYPE), padded.as_bytes(), None, None), EvidenceClientError::Protocol { status: 403, code: None, operation: None, + retry_after_seconds: None, } ); } #[test] fn an_unusable_operation_identifier_is_dropped_not_copied() { - let hostile = br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized","operation":"01AB\nsubject=Amina"}"#; + let hostile = br#"{"type":"about:blank","title":"t","status":403,"code":"not_authorized","operation":"01AB\nrole=subject"}"#; assert_eq!( - map_problem(403, Some(PROBLEM_MEDIA_TYPE), hostile, None), + map_problem(403, Some(PROBLEM_MEDIA_TYPE), hostile, None, None), EvidenceClientError::Denied { status: 403, code: "not_authorized".to_owned(), @@ -282,6 +403,7 @@ mod tests { Some("application/problem+json; charset=utf-8"), &problem_json(403, "not_authorized"), None, + None, ); assert!(matches!(mapped, EvidenceClientError::Denied { .. })); } diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 6d000c9e3..f7b937ccd 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -439,8 +439,18 @@ async fn a_response_under_the_wrong_media_type_is_refused() { deployment.runtime.jwks().clone(), )) .unwrap(); + // A prepared request is good for one send, and the one above is spent, + // so the replay leg carries its own. The media type is refused before + // anything about the request is compared to anything in the response. + let replayed_request = replayed + .prepare(spec( + &definitions, + "media-type", + SubjectExpectations::AcceptFirstUse, + )) + .unwrap(); // Held so the stub outlives the exchange it answers. - let refusal = replayed.send(&prepared).await; + let refusal = replayed.send(&replayed_request).await; drop(replay); Ok(refusal) } From 1a3fa559c6472d2cc04eb88dc04255ac62346642 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 04:49:57 +0700 Subject: [PATCH 12/67] fix(evidence): report client transport failures faithfully A failed body read told every adopter their response was too large, even when the cause was the request timeout elapsing mid-body, which is the likely failure because the total timeout runs until the body finishes. The same call site threw away the status and the correlation identifier it already held. An empty pinned key set was accepted and then refused once per request inside the verifier. A doubled separator in the base path put "//" in every request path. Security review notes: - Transport classification now matches the reader's own error: the three size variants stay ResponseTooLarge, a timeout is reported as a timeout, and anything else, including a variant this crate does not know yet, is the coarse exchange failure. No part of the underlying error text reaches a diagnostic, so the classification is the only thing that changed. - A non-2xx answer whose body cannot be read now reports the status and the deployment's identifier instead of a transport failure. Both were read from headers before the body was touched, and neither is response content. A 2xx read failure stays a transport failure, since there is no code worth reporting, only the reason the bytes never arrived. - The pinned key set is the load-bearing trust decision, so an empty one is refused at construction. The verifier's per-request refusal is unchanged; this only stops the failure from looking like a deployment fault. - One sanitizing rule for the correlation identifier, applied to both the problem body's member and the response header, replacing two copies that had already drifted. The value is judged exactly as received: HTTP field parsing has removed the whitespace the grammar permits, and trimming would rewrite a value the deployment chose rather than refuse it. A hostile header value is dropped, proven end to end. - New security-path tests: transport classification, refusal of unusable pinned certificate material at construction, a redirect surfacing as a protocol failure with zero requests reaching the redirect target, and the configured user agent reaching the wire. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/client.rs | 419 ++++++++++++++++-- crates/registry-evidence-client/src/config.rs | 84 +++- .../registry-evidence-client/src/problem.rs | 65 ++- 3 files changed, 505 insertions(+), 63 deletions(-) diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index 7a14bc7ff..abae38b47 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -11,7 +11,7 @@ use registry_evidence_verifier::{ verifier::{verify_flattened_jws, ExpectedSubjectDocument}, EVIDENCE_JWS_MEDIA_TYPE, }; -use registry_platform_httputil::read_bounded; +use registry_platform_httputil::{read_bounded, BoundedReadError}; use reqwest::{ header::{HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}, Method, StatusCode, @@ -24,7 +24,7 @@ use crate::{ definitions::EvidenceDefinitionsDocument, error::{EvidenceClientError, TransportKind}, prepare::{EvidenceRequestSpec, PreparedEvidenceRequest, SubjectExpectations}, - problem::{essence, map_problem}, + problem::{essence, map_problem, sanitized_operation}, request::EvidenceRequestBody, }; @@ -344,7 +344,11 @@ impl EvidenceClient { expected_media_type: &str, ) -> Result { let status = response.status().as_u16(); - let operation = sanitized_correlation_id(&response); + let operation = response + .headers() + .get(CORRELATION_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(sanitized_operation); let media_type = response .headers() .get(CONTENT_TYPE) @@ -356,14 +360,24 @@ impl EvidenceClient { .and_then(|value| value.to_str().ok()) .and_then(|value| value.trim().parse::().ok()); - let body = read_bounded(response, self.config.max_response_bytes) - .await - .map_err(|_| { - // Both an oversized body and a failed read collapse here: the - // bound is the caller's, and the underlying text is not - // something this crate copies into a diagnostic. - EvidenceClientError::transport(TransportKind::ResponseTooLarge) - })?; + let body = match read_bounded(response, self.config.max_response_bytes).await { + Ok(body) => body, + // The status and the correlation identifier arrived before the body + // did. A refusal keeps them, because they are the whole support + // workflow this crate offers and the unread body would have carried + // nothing else the caller may act on. + Err(_) if !(200..300).contains(&status) => { + return Err(EvidenceClientError::Protocol { + status, + code: None, + operation, + retry_after_seconds: None, + }) + } + // An answer meant as a success has no status or code worth + // reporting, only the reason its bytes never arrived. + Err(error) => return Err(EvidenceClientError::transport(read_failure_kind(&error))), + }; if !(200..300).contains(&status) { return Err(map_problem( @@ -435,20 +449,24 @@ fn serialize_request(body: &EvidenceRequestBody) -> Result, EvidenceClie .map_err(|_| EvidenceClientError::configuration("the request body cannot be serialized")) } -/// The correlation identifier, kept only when it is a bounded alphanumeric -/// value. A deployment cannot use this header to inject text into a relying -/// party's records. -fn sanitized_correlation_id(response: &reqwest::Response) -> Option { - let value = response - .headers() - .get(CORRELATION_HEADER)? - .to_str() - .ok()? - .trim(); - let acceptable = !value.is_empty() - && value.len() <= 64 - && value.bytes().all(|byte| byte.is_ascii_alphanumeric()); - acceptable.then(|| value.to_owned()) +/// Why a bounded read failed, in the terms the caller can act on. +/// +/// The distinction matters most for a timeout, which is the likely failure: the +/// configured total timeout runs until the body finishes, so an answer that +/// starts and stalls elapses here rather than at connection setup. No part of the +/// underlying error text is copied into the reported failure. +fn read_failure_kind(error: &BoundedReadError) -> TransportKind { + match error { + BoundedReadError::ContentLengthExceeded { .. } + | BoundedReadError::BodyTooLarge { .. } + | BoundedReadError::LengthOverflow => TransportKind::ResponseTooLarge, + BoundedReadError::Transport(error) if error.is_timeout() => TransportKind::Timeout, + // The reader's error type is open, so a variant this crate does not know + // yet becomes the coarse exchange failure. It must never become a claim + // about the response size, which is the one thing an adopter would act on + // by raising their own bound. + _ => TransportKind::Exchange, + } } /// Read the role-bound subject bindings out of a response that has not been @@ -498,16 +516,39 @@ mod tests { }, AssuranceProfile, }; - use std::sync::Arc; - use wiremock::{Mock, MockServer, ResponseTemplate}; + use registry_platform_httputil::BoundedReadError; + use std::{net::TcpListener, sync::Arc, time::Duration}; + use wiremock::{ + matchers::{any, header, method, path}, + Mock, MockServer, ResponseTemplate, + }; - fn client_for(base_url: &str, fixture: &SignedEvidenceFixture) -> EvidenceClient { - EvidenceClient::new(EvidenceClientConfig::new( + /// The identifier shape the deployment publishes: a ULID. + const OPERATION: &str = "01JQ0QZ8YHZ0000000000000AB"; + + fn config_for(base_url: &str, fixture: &SignedEvidenceFixture) -> EvidenceClientConfig { + EvidenceClientConfig::new( Url::parse(base_url).expect("the base URL parses"), Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), fixture.trusted_jwks.clone(), - )) - .expect("the client is configured") + ) + } + + fn client_for(base_url: &str, fixture: &SignedEvidenceFixture) -> EvidenceClient { + EvidenceClient::new(config_for(base_url, fixture)).expect("the client is configured") + } + + /// A loopback origin with nothing listening on it. The port is reserved and + /// released, so the connection attempt is refused rather than answered. + fn closed_loopback_origin() -> String { + let reservation = + TcpListener::bind(("127.0.0.1", 0)).expect("a loopback port is available"); + let port = reservation + .local_addr() + .expect("the reservation has an address") + .port(); + drop(reservation); + format!("http://127.0.0.1:{port}") } fn client(fixture: &SignedEvidenceFixture) -> EvidenceClient { @@ -804,8 +845,8 @@ mod tests { let prepared = client .prepare(spec(SubjectExpectations::AcceptFirstUse)) .expect("the specification is accepted"); - Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/v1/evidence")) + Mock::given(method("POST")) + .and(path("/v1/evidence")) .respond_with(ResponseTemplate::new(200).set_body_raw( fixture.sign(prepared.request_nonce()), EVIDENCE_JWS_MEDIA_TYPE, @@ -840,21 +881,120 @@ mod tests { /// A body the problem contract does not cover leaves the client with nothing /// to say about the failure, which is exactly when the deployment's own - /// identifier for the exchange matters. + /// identifier for the exchange matters. A header value outside the rule is + /// still dropped rather than copied into the relying party's records. #[tokio::test] async fn a_failure_carries_the_correlation_identifier_even_with_an_unreadable_body() { + let fixture = signed_evidence(); + for (sent, expected) in [ + (OPERATION, Some(OPERATION.to_owned())), + ("01AB role=subject", None), + ] { + let server = MockServer::start().await; + let client = client_for(&server.uri(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with( + ResponseTemplate::new(400) + .insert_header(CORRELATION_HEADER, sent) + .set_body_raw(b"a gateway wrote this".to_vec(), "text/html"), + ) + .mount(&server) + .await; + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("a body outside the contract is a protocol failure"), + EvidenceClientError::Protocol { + status: 400, + code: None, + operation: expected, + retry_after_seconds: None, + }, + "the header carried {sent:?}" + ); + } + } + + /// The four ways a bounded read can fail are four different things to tell an + /// adopter. A timeout while the body streams is the likely one, because the + /// request timeout runs until the body finishes, and reporting it as an + /// oversized response would send the adopter to the wrong place. + #[tokio::test] + async fn a_failed_body_read_reports_its_own_cause() { + for error in [ + BoundedReadError::ContentLengthExceeded { + content_length: 2, + max_bytes: 1, + }, + BoundedReadError::BodyTooLarge { max_bytes: 1 }, + BoundedReadError::LengthOverflow, + ] { + assert_eq!( + read_failure_kind(&error), + TransportKind::ResponseTooLarge, + "{error}" + ); + } + let fixture = signed_evidence(); let server = MockServer::start().await; - let client = client_for(&server.uri(), &fixture); + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2))) + .mount(&server) + .await; + let http = build_client( + &config_for(&server.uri(), &fixture).with_request_timeout(Duration::from_millis(100)), + ) + .expect("the outbound client builds"); + let timeout = http + .get(server.uri()) + .send() + .await + .expect_err("the request timeout elapses"); + assert!(timeout.is_timeout(), "{timeout:?}"); + assert_eq!( + read_failure_kind(&BoundedReadError::Transport(timeout)), + TransportKind::Timeout + ); + + let refused = http + .get(closed_loopback_origin()) + .send() + .await + .expect_err("nothing is listening"); + assert!(!refused.is_timeout(), "{refused:?}"); + assert_eq!( + read_failure_kind(&BoundedReadError::Transport(refused)), + TransportKind::Exchange + ); + } + + /// A gateway can answer a refusal with a body far larger than the contract's, + /// and the read then fails. The status and the deployment's identifier were + /// already in hand, so the failure still carries the support workflow this + /// crate advertises. + #[tokio::test] + async fn a_refusal_whose_body_cannot_be_read_keeps_its_status_and_identifier() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = + EvidenceClient::new(config_for(&server.uri(), &fixture).with_max_response_bytes(32)) + .expect("the client is configured"); let prepared = client .prepare(spec(SubjectExpectations::AcceptFirstUse)) .expect("the specification is accepted"); - Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/v1/evidence")) + Mock::given(method("POST")) + .and(path("/v1/evidence")) .respond_with( - ResponseTemplate::new(400) - .insert_header(CORRELATION_HEADER, "01JQ0QZ8YHZ0000000000000AB") - .set_body_raw(b"a gateway wrote this".to_vec(), "text/html"), + ResponseTemplate::new(502) + .insert_header(CORRELATION_HEADER, OPERATION) + .set_body_raw(vec![b'a'; 4096], "text/html"), ) .mount(&server) .await; @@ -863,16 +1003,209 @@ mod tests { client .send(&prepared) .await - .expect_err("a body outside the contract is a protocol failure"), + .expect_err("the body is beyond the bound"), EvidenceClientError::Protocol { - status: 400, + status: 502, code: None, - operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + operation: Some(OPERATION.to_owned()), retry_after_seconds: None, } ); } + /// An answer the deployment meant as a success, whose body cannot be read, is + /// a transport failure: there is no status or code worth reporting, only the + /// reason the bytes never arrived. + #[tokio::test] + async fn a_successful_answer_whose_body_cannot_be_read_is_a_transport_failure() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = + EvidenceClient::new(config_for(&server.uri(), &fixture).with_max_response_bytes(32)) + .expect("the client is configured"); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(vec![b'a'; 4096], EVIDENCE_JWS_MEDIA_TYPE), + ) + .mount(&server) + .await; + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("the body is beyond the bound"), + EvidenceClientError::transport(TransportKind::ResponseTooLarge) + ); + } + + /// A deployment that is not listening is a connection failure, not a refusal + /// and not a protocol fault. + #[tokio::test] + async fn an_unreachable_deployment_reports_a_connection_failure() { + let fixture = signed_evidence(); + let client = client_for(&closed_loopback_origin(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("nothing is listening"), + EvidenceClientError::transport(TransportKind::Connect) + ); + } + + /// The configured total timeout is the relying party's own bound on how long + /// a decision may wait. + #[tokio::test] + async fn an_elapsed_request_timeout_reports_a_timeout() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2))) + .mount(&server) + .await; + let client = EvidenceClient::new( + config_for(&server.uri(), &fixture).with_request_timeout(Duration::from_millis(100)), + ) + .expect("the client is configured"); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("the deployment answers too late"), + EvidenceClientError::transport(TransportKind::Timeout) + ); + } + + /// Pinning the certificate authorities replaces the platform store, so + /// material the client cannot use has to fail at construction. Falling back to + /// the platform store would quietly widen who may vouch for the deployment. + #[tokio::test] + async fn unusable_pinned_certificate_material_is_refused_at_construction() { + let fixture = signed_evidence(); + for (bundle, reason) in [ + ( + b"".to_vec(), + "the pinned certificate authority bundle carries no certificate", + ), + ( + b"not a certificate".to_vec(), + "the pinned certificate authority bundle carries no certificate", + ), + // The PEM framing is accepted and the content is rejected later, when + // the outbound client is built, so this one surfaces as the coarse + // options failure. It is still refused at construction, which is what + // keeps the platform store from quietly taking over. + ( + b"-----BEGIN CERTIFICATE-----\nnot base64 at all\n-----END CERTIFICATE-----\n" + .to_vec(), + "the outbound client options are not usable", + ), + ] { + assert_eq!( + EvidenceClient::new( + config_for("https://evidence.example.org", &fixture) + .with_trusted_root_certificates(bundle.clone()) + ) + .map(|_| ()) + .expect_err("unusable trust material is refused"), + EvidenceClientError::configuration(reason), + "{:?}", + String::from_utf8_lossy(&bundle) + ); + } + } + + /// A redirect is not part of the response contract. Following one would carry + /// the credential to a host the relying party never configured, so the client + /// reports the answer as it stands and sends nothing onward. + #[tokio::test] + async fn a_redirect_is_refused_and_the_credential_never_follows_it() { + let fixture = signed_evidence(); + let elsewhere = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .mount(&elsewhere) + .await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with(ResponseTemplate::new(302).insert_header( + "location", + format!("{}/v1/evidence", elsewhere.uri()).as_str(), + )) + .mount(&server) + .await; + let client = client_for(&server.uri(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("a redirect is not an Evidence response"), + EvidenceClientError::Protocol { + status: 302, + code: None, + operation: None, + retry_after_seconds: None, + } + ); + assert!( + elsewhere + .received_requests() + .await + .expect("the stub records what it received") + .is_empty(), + "the credential must not follow a redirect" + ); + } + + /// An adopter's own user agent is how a deployment operator recognizes the + /// relying party in its logs, so it has to reach the wire. + #[tokio::test] + async fn the_configured_user_agent_reaches_the_deployment() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = EvidenceClient::new( + config_for(&server.uri(), &fixture).with_user_agent("relying-party/1.0"), + ) + .expect("the client is configured"); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .and(header("user-agent", "relying-party/1.0")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + fixture.sign(prepared.request_nonce()), + EVIDENCE_JWS_MEDIA_TYPE, + )) + .expect(1) + .mount(&server) + .await; + + client + .send(&prepared) + .await + .expect("the deployment recognized the user agent"); + } + #[test] fn debug_output_never_carries_a_response_body_or_a_credential() { let fixture = signed_evidence(); diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index f4627162c..15f008e3f 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -127,6 +127,19 @@ impl EvidenceClientConfig { "the base URL must carry no credentials, query, or fragment", )); } + if let Some(mut segments) = self.base_url.path_segments().map(Iterator::peekable) { + // A single trailing separator is the ordinary way to write a + // deployment prefix, and `endpoint` drops it. Any other empty + // segment would put `//` in every request path, which the deployment + // answers with a confusing 404. + while let Some(segment) = segments.next() { + if segment.is_empty() && segments.peek().is_some() { + return Err(EvidenceClientError::configuration( + "the base URL path must carry no empty segment other than a trailing separator", + )); + } + } + } // A bearer credential in cleartext is only acceptable when it cannot // leave the host, which is the local development and tutorial case. The // accepted forms are the ones an adopter types: either loopback numeric @@ -147,6 +160,14 @@ impl EvidenceClientConfig { "the base URL must use HTTPS, or HTTP with a loopback host", )); } + // The pinned key set is the load-bearing decision, so it fails here + // rather than once per request inside the verifier, where an empty set + // looks to an adopter like a deployment fault. + if self.trusted_jwks.keys.is_empty() { + return Err(EvidenceClientError::configuration( + "the pinned key set must carry at least one verification key", + )); + } if self.max_response_bytes == 0 { return Err(EvidenceClientError::configuration( "the response bound must allow at least one byte", @@ -185,10 +206,23 @@ mod tests { EvidenceClientConfig::new( Url::parse(base_url).expect("the test URL parses"), Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), - JwksDocument { keys: Vec::new() }, + one_key(), ) } + /// A key set with one member. Only its presence matters here; the verifier + /// owns everything about a key's content. + fn one_key() -> JwksDocument { + JwksDocument { + keys: vec![serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "kid": "test-key", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + })], + } + } + /// Every loopback form an adopter or a tutorial actually types. #[test] fn https_and_loopback_http_are_accepted() { @@ -243,6 +277,54 @@ mod tests { } } + /// An empty segment in the base path would put `//` in every request path, + /// and the deployment would answer each one with a confusing 404. A single + /// trailing separator is the ordinary way to write a prefix. + #[test] + fn a_base_url_path_with_an_empty_segment_is_refused() { + for base_url in [ + "https://evidence.example.org//", + "https://evidence.example.org/registry//", + "https://evidence.example.org//registry", + "https://evidence.example.org/registry//tenant", + ] { + assert_eq!( + config(base_url) + .validate() + .expect_err("{base_url} was accepted"), + EvidenceClientError::configuration( + "the base URL path must carry no empty segment other than a trailing separator" + ), + "{base_url}" + ); + } + for base_url in [ + "https://evidence.example.org", + "https://evidence.example.org/", + "https://evidence.example.org/registry", + "https://evidence.example.org/registry/", + ] { + config(base_url) + .validate() + .unwrap_or_else(|error| panic!("{base_url} was refused: {error}")); + } + } + + /// The pinned key set is the load-bearing decision, so an empty one fails + /// here rather than once per request inside the verifier, where it looks like + /// a deployment fault. + #[test] + fn an_empty_pinned_key_set_is_refused() { + let mut config = config("https://evidence.example.org"); + config.trusted_jwks = JwksDocument { keys: Vec::new() }; + assert_eq!( + config.validate().expect_err("an empty key set is refused"), + EvidenceClientError::configuration( + "the pinned key set must carry at least one verification key" + ) + ); + } + #[test] fn unusable_bounds_are_refused() { assert!(config("https://evidence.example.org") diff --git a/crates/registry-evidence-client/src/problem.rs b/crates/registry-evidence-client/src/problem.rs index e122744a4..c2a31b4ac 100644 --- a/crates/registry-evidence-client/src/problem.rs +++ b/crates/registry-evidence-client/src/problem.rs @@ -36,11 +36,12 @@ pub(crate) struct ProblemBody { /// the two answers the contract permits a bounded wait on: the rate-limited /// refusal, and a transient dependency or service failure. /// -/// `header_operation` is the identifier the response header carried. It is the +/// `header_operation` is the identifier the response header carried, already put +/// through [`sanitized_operation`] by the caller that read the header. It is the /// fallback for every failure that can name one, because the case where the body /// cannot be read is exactly the case where the deployment's own identifier for /// the exchange is all a relying party can take to support. A readable body's own -/// identifier wins when it satisfies the rule; both are held to the same rule. +/// identifier wins when it satisfies the same rule. pub(crate) fn map_problem( status: u16, media_type: Option<&str>, @@ -48,16 +49,16 @@ pub(crate) fn map_problem( retry_after_seconds: Option, header_operation: Option<&str>, ) -> EvidenceClientError { - let header_operation = header_operation.and_then(sanitized_operation); let Some(problem) = parse_problem(media_type, body) else { return EvidenceClientError::Protocol { status, code: None, - operation: header_operation, + operation: header_operation.map(str::to_owned), retry_after_seconds: None, }; }; - let operation = sanitized_operation(&problem.operation).or(header_operation); + let operation = + sanitized_operation(&problem.operation).or_else(|| header_operation.map(str::to_owned)); match (status, problem.code.as_str()) { (401 | 403 | 429, code) => EvidenceClientError::Denied { status, @@ -119,7 +120,14 @@ fn is_contract_code(code: &str) -> bool { /// The operation identifier is an opaque support-correlation value. Only a /// bounded alphanumeric value is kept, so a hostile deployment cannot use it to /// inject text into a relying party's records. -fn sanitized_operation(operation: &str) -> Option { +/// +/// This is the one rule, applied to both places the identifier can arrive from: +/// the problem body's own member, and the response header the client reads. The +/// value is judged exactly as received, with no trimming. HTTP field parsing has +/// already removed the optional whitespace the field grammar permits around a +/// header value, and a body member is exact data, so trimming here would only +/// rewrite a value the deployment chose rather than refuse it. +pub(crate) fn sanitized_operation(operation: &str) -> Option { let acceptable = !operation.is_empty() && operation.len() <= 64 && operation.bytes().all(|byte| byte.is_ascii_alphanumeric()); @@ -294,22 +302,41 @@ mod tests { retry_after_seconds: None, } ); + } - // A header value outside the rule is dropped exactly like a body value. + /// One rule, for the body's own member and for the header the client reads. + /// A hostile deployment must not be able to write text of its choosing into a + /// relying party's records through either. + #[test] + fn only_a_bounded_alphanumeric_identifier_is_kept() { assert_eq!( - map_problem( - 400, - Some("text/html"), - b"", + sanitized_operation(OPERATION), + Some(OPERATION.to_owned()), + "the deployment's own identifier shape is kept" + ); + for hostile in [ + "", + "01AB\nrole=subject", + "01AB role=subject", + "01AB\trole=subject", + // Surrounding whitespace is not trimmed away, so a value carrying it + // is refused rather than rewritten. + " 01AB", + "01AB ", + "01AB;binding=urn:evidence:subject:v1_AAA", + "01AB\u{00e9}", + &"A".repeat(65), + ] { + assert_eq!( + sanitized_operation(hostile), None, - Some("01AB role=subject"), - ), - EvidenceClientError::Protocol { - status: 400, - code: None, - operation: None, - retry_after_seconds: None, - } + "{hostile:?} was kept as an identifier" + ); + } + assert_eq!( + sanitized_operation(&"A".repeat(64)), + Some("A".repeat(64)), + "the bound itself is acceptable" ); } From 5599ea2864b070c332117fd7ab15dbad05fa76c8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 05:04:09 +0700 Subject: [PATCH 13/67] refactor(evidence): polish the client SDK surface for bindings Narrow what the crate exposes so a future language binding has a small, stable surface to carry across a boundary, and make each refusal name the field a caller has to fix. Surface: - the problem-mapping module and the request wire types become crate internal; only SelectorValue stays public, because a caller supplies selector values - lib.rs re-exports the request bounds beside the configuration defaults, so every value a refusal can spell is nameable - EvidenceClientError::kind returns a stable machine-readable discriminant for metric labels, structured log fields, and binding boundaries - verify_at becomes the public verify_as_of, for re-verifying a retained response at a decision instant Clarity: - one get_json helper behind discover and fetch_jwks - a named Credential enum instead of a bare boolean at the exchange call sites - the six identifier checks each carry their own reason - RequestNonce::parse no longer implies a seam for an outside nonce - rationale for refusing redirects and ignoring proxy environment variables, in code and in the README - the README and crate docs state that the HTTP methods need a tokio-compatible reactor No security-relevant behavior changes: the refusal set, the credential handling, the redirect and proxy policy, and the verification seam are all as before. The media type comparison drops two allocations while staying case-insensitive over the essence, which two cases now pin. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/README.md | 10 + crates/registry-evidence-client/src/client.rs | 199 ++++++++++++------ crates/registry-evidence-client/src/error.rs | 72 +++++++ crates/registry-evidence-client/src/lib.rs | 17 +- crates/registry-evidence-client/src/nonce.rs | 8 +- .../registry-evidence-client/src/prepare.rs | 109 ++++++++-- .../registry-evidence-client/src/problem.rs | 40 ++-- .../registry-evidence-client/src/request.rs | 28 ++- .../tests/against_a_real_deployment.rs | 142 +++++-------- .../tests/platform_support.rs | 15 ++ 10 files changed, 454 insertions(+), 186 deletions(-) create mode 100644 crates/registry-evidence-client/tests/platform_support.rs diff --git a/crates/registry-evidence-client/README.md b/crates/registry-evidence-client/README.md index d0659160d..474885754 100644 --- a/crates/registry-evidence-client/README.md +++ b/crates/registry-evidence-client/README.md @@ -21,6 +21,12 @@ judgement about a response is made by `registry-evidence-verifier`. out-of-band pinning workflow only. - `TokenProvider` and `StaticToken` for the bearer credential the deployment's resource-server authentication expects. +- `EvidenceClient::verify_as_of`: the same verification at an instant the caller + names, for re-verifying a response it retained. + +Preparing and verifying are synchronous. The HTTP methods are `reqwest` calls, so +awaiting them requires a tokio-compatible reactor even though nothing in this +crate names a runtime. ## Typical Use @@ -79,6 +85,10 @@ async fn accept( failure carries the deployment's operation identifier for support correlation. - Every response is read under a caller-configured byte bound before it is parsed. +- Redirects are not followed, and the proxy environment variables are ignored. A + redirect or an ambient proxy variable would otherwise present the credential to + a host the integrator never configured, and a proxy would terminate the TLS + session the pinned certificate authorities were meant to authenticate. ## Testing diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index abae38b47..ced837c36 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -41,6 +41,15 @@ const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; /// The opaque per-request identifier the deployment returns. const CORRELATION_HEADER: &str = "x-request-id"; +/// Whether an exchange carries the relying party's bearer credential. +/// +/// Named rather than a boolean, so a call site states which of the two it means +/// instead of leaving the reader to recover it from a bare `true`. +enum Credential { + Required, + None, +} + /// A relying party's connection to one Evidence deployment. #[derive(Debug)] pub struct EvidenceClient { @@ -155,19 +164,8 @@ impl EvidenceClient { /// party what it may ask for; it never supplies verification expectations /// for a request already in flight. pub async fn discover(&self) -> Result { - let url = self.endpoint(DEFINITIONS_PATH)?; - let request = self - .http - .request(Method::GET, url) - .header(ACCEPT, JSON_MEDIA_TYPE); - let response = self.exchange(request, true).await?; - let body = self.expect_success(response, JSON_MEDIA_TYPE).await?; - serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { - status: StatusCode::OK.as_u16(), - code: None, - operation: body.operation, - retry_after_seconds: None, - }) + self.get_json(DEFINITIONS_PATH, JSON_MEDIA_TYPE, Credential::Required) + .await } /// Read the deployment's published verification key set. @@ -178,19 +176,11 @@ impl EvidenceClient { /// calls this. A key set fetched from the same origin as the response it /// would verify establishes nothing. pub async fn fetch_jwks(&self) -> Result { - let url = self.endpoint(JWKS_PATH)?; - let request = self - .http - .request(Method::GET, url) - .header(ACCEPT, JWKS_MEDIA_TYPE); - let response = self.exchange(request, false).await?; - let body = self.expect_success(response, JWKS_MEDIA_TYPE).await?; - serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { - status: StatusCode::OK.as_u16(), - code: None, - operation: body.operation, - retry_after_seconds: None, - }) + // The published key set is public, and it is not a trust anchor here, so + // there is nothing to gain by presenting the relying party's credential + // to fetch it. + self.get_json(JWKS_PATH, JWKS_MEDIA_TYPE, Credential::None) + .await } /// Send one prepared request and read the signed response. @@ -219,7 +209,7 @@ impl EvidenceClient { .header(ACCEPT, EVIDENCE_JWS_MEDIA_TYPE) .header(CONTENT_TYPE, JSON_MEDIA_TYPE) .body(body); - let response = self.exchange(request, true).await?; + let response = self.exchange(request, Credential::Required).await?; self.expect_success(response, EVIDENCE_JWS_MEDIA_TYPE).await } @@ -236,7 +226,7 @@ impl EvidenceClient { prepared: &PreparedEvidenceRequest, response: &RawEvidenceResponse, ) -> Result { - self.verify_at(prepared, response, Utc::now()) + self.verify_as_of(prepared, response, Utc::now()) } /// Request evidence and verify it, in one step. @@ -252,8 +242,30 @@ impl EvidenceClient { self.verify(prepared, &response) } - /// Verification at an explicit instant, so a test can pin the clock. - pub(crate) fn verify_at( + /// Verify a retained response as of an explicit instant. + /// + /// [`EvidenceClient::verify`] judges a response against the current clock, + /// which is right when the response has just arrived. This variant lets the + /// relying party name the instant instead, and the two cases that need it are + /// both about a response the relying party already holds: + /// + /// - Re-verifying a retained response when the decision is actually made, + /// rather than when the bytes arrived. The assertion's own validity + /// interval, plus the request's stated clock skew, then decides whether it + /// still answers the question. + /// - Replaying a retained transaction record: the same bytes, the same + /// retained prepared request, and the instant the original decision was + /// taken, so an audit reaches the same verdict the relying party did. + /// + /// The instant only moves the clock. Every other expectation is the one the + /// request closed, and the trusted key set is the one pinned at + /// construction. Passing a future instant does not extend an assertion's + /// validity; it only asks whether the assertion would have been acceptable + /// then. + /// + /// The parameter is a [`chrono::DateTime`], the same instant type the + /// portable verifier's own policy takes. + pub fn verify_as_of( &self, prepared: &PreparedEvidenceRequest, response: &RawEvidenceResponse, @@ -279,6 +291,33 @@ impl EvidenceClient { }) } + /// Read one JSON document from a GET endpoint under the base URL. + /// + /// The two documents this serves, discovery and the published key set, are + /// both authoring input rather than verification input, and a body that does + /// not parse is a protocol failure rather than a refusal: the deployment + /// answered, and the answer was not the document it promised. + async fn get_json( + &self, + path: &str, + media_type: &str, + credential: Credential, + ) -> Result { + let url = self.endpoint(path)?; + let request = self + .http + .request(Method::GET, url) + .header(ACCEPT, media_type); + let response = self.exchange(request, credential).await?; + let body = self.expect_success(response, media_type).await?; + serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { + status: StatusCode::OK.as_u16(), + code: None, + operation: body.operation, + retry_after_seconds: None, + }) + } + /// Resolve one endpoint under the configured base URL. fn endpoint(&self, path: &str) -> Result { // `join` on a base whose path lacks a trailing separator would discard @@ -301,25 +340,28 @@ impl EvidenceClient { async fn exchange( &self, request: reqwest::RequestBuilder, - authenticated: bool, + credential: Credential, ) -> Result { - let request = if authenticated { - let token = self.config.token_provider.bearer_token().await?; - // The plaintext credential exists in one scrubbed buffer here. The - // header value reqwest owns afterwards cannot be zeroized, which is - // why it is marked sensitive below. - let mut credential = Zeroizing::new(String::with_capacity(7 + token.expose().len())); - credential.push_str("Bearer "); - credential.push_str(token.expose()); - let mut value = HeaderValue::from_str(&credential).map_err(|_| { - EvidenceClientError::configuration("the credential is not a usable header value") - })?; - // The credential must never reach a diagnostic, and reqwest honors - // this marking when it formats a request. - value.set_sensitive(true); - request.header(AUTHORIZATION, value) - } else { - request + let request = match credential { + Credential::Required => { + let token = self.config.token_provider.bearer_token().await?; + // The plaintext credential exists in one scrubbed buffer here. + // The header value reqwest owns afterwards cannot be zeroized, + // which is why it is marked sensitive below. + let mut header = Zeroizing::new(String::with_capacity(7 + token.expose().len())); + header.push_str("Bearer "); + header.push_str(token.expose()); + let mut value = HeaderValue::from_str(&header).map_err(|_| { + EvidenceClientError::configuration( + "the credential is not a usable header value", + ) + })?; + // The credential must never reach a diagnostic, and reqwest + // honors this marking when it formats a request. + value.set_sensitive(true); + request.header(AUTHORIZATION, value) + } + Credential::None => request, }; request.send().await.map_err(|error| { let kind = if error.is_timeout() { @@ -389,7 +431,9 @@ impl EvidenceClient { )); } if status != StatusCode::OK.as_u16() - || media_type.as_deref().map(essence) != Some(expected_media_type.to_owned()) + || !media_type + .as_deref() + .is_some_and(|value| essence(value).eq_ignore_ascii_case(expected_media_type)) { return Err(EvidenceClientError::Protocol { status, @@ -407,7 +451,15 @@ fn build_client(config: &EvidenceClientConfig) -> Result &'static str { + match self { + Self::Configuration { .. } => "configuration", + Self::Nonce(_) => "nonce", + Self::Token(_) => "token", + Self::Transport { .. } => "transport", + Self::Denied { .. } => "denied", + Self::NotAvailable { .. } => "not_available", + Self::Protocol { .. } => "protocol", + Self::Verification(_) => "verification", + } + } + /// The opaque per-request identifier to quote when asking the deployment /// operator about this failure. #[must_use] @@ -142,4 +164,54 @@ mod tests { ); assert_eq!(transport.operation(), None); } + + /// The discriminant is what a binding, a metric label, or a caller's own + /// branch reads, so every variant has one and no two share it. + #[test] + fn every_failure_reports_its_own_stable_kind() { + let cases = [ + ( + EvidenceClientError::configuration("unusable"), + "configuration", + ), + (EvidenceClientError::Nonce(NonceError::Entropy), "nonce"), + (EvidenceClientError::Token(TokenError::Unavailable), "token"), + ( + EvidenceClientError::transport(TransportKind::Connect), + "transport", + ), + ( + EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: None, + retry_after_seconds: None, + }, + "denied", + ), + ( + EvidenceClientError::NotAvailable { operation: None }, + "not_available", + ), + ( + EvidenceClientError::Protocol { + status: 200, + code: None, + operation: None, + retry_after_seconds: None, + }, + "protocol", + ), + ( + EvidenceClientError::Verification(VerificationError::Signature), + "verification", + ), + ]; + for (error, kind) in &cases { + assert_eq!(error.kind(), *kind, "{error}"); + } + let kinds: std::collections::BTreeSet<&str> = + cases.iter().map(|(error, _)| error.kind()).collect(); + assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); + } } diff --git a/crates/registry-evidence-client/src/lib.rs b/crates/registry-evidence-client/src/lib.rs index 92493c641..ebbbc855b 100644 --- a/crates/registry-evidence-client/src/lib.rs +++ b/crates/registry-evidence-client/src/lib.rs @@ -65,6 +65,15 @@ //! source access and a second audit entry there for one relying-party decision. //! Verification is exempt: it is offline and idempotent, so a retained response //! may be re-verified as often as the relying party likes. +//! +//! # What the async surface requires +//! +//! Nothing in this crate names a runtime, and preparing and verifying are +//! synchronous. The HTTP methods, however, are `reqwest` calls, and `reqwest` +//! needs a tokio-compatible reactor to drive them, so an application that awaits +//! [`EvidenceClient::send`], [`EvidenceClient::request_and_verify`], +//! [`EvidenceClient::discover`], or [`EvidenceClient::fetch_jwks`] has to do so +//! on one. pub mod client; pub mod config; @@ -72,10 +81,13 @@ pub mod definitions; pub mod error; pub mod nonce; pub mod prepare; -pub mod problem; pub mod request; pub mod token; +/// The closed problem contract is an internal parsing detail. What a caller acts +/// on is the mapped failure in [`error`], never a problem body. +mod problem; + #[cfg(test)] mod fixtures; @@ -93,6 +105,9 @@ pub use error::{EvidenceClientError, TransportKind}; pub use nonce::{NonceError, RequestNonce}; pub use prepare::{ EvidenceRequestSpec, PreparedEvidenceRequest, SubjectExpectations, SubjectRequest, + MAXIMUM_EXPECTED_OUTPUTS, MAXIMUM_IDENTIFIER_BYTES, MAXIMUM_SELECTOR_INTEGER, + MAXIMUM_SELECTOR_STRING_BYTES, MAXIMUM_SELECTOR_VALUES, MAXIMUM_SUBJECTS, + MINIMUM_SELECTOR_INTEGER, }; pub use request::SelectorValue; pub use token::{BearerToken, StaticToken, TokenError, TokenProvider}; diff --git a/crates/registry-evidence-client/src/nonce.rs b/crates/registry-evidence-client/src/nonce.rs index 2446a0b7f..0025718dc 100644 --- a/crates/registry-evidence-client/src/nonce.rs +++ b/crates/registry-evidence-client/src/nonce.rs @@ -28,8 +28,12 @@ impl RequestNonce { Ok(Self(URL_SAFE_NO_PAD.encode(bytes))) } - /// Accept an externally retained nonce, such as one read back from a - /// relying party's own request record. + /// Check that a retained nonce string is still the canonical encoding, such + /// as one read back from a relying party's own request record. + /// + /// This does not supply a nonce to a request. Every prepared request draws + /// its own from [`RequestNonce::generate`], and there is no seam for + /// substituting an outside value. pub fn parse(value: &str) -> Result { if is_canonical(value) { Ok(Self(value.to_owned())) diff --git a/crates/registry-evidence-client/src/prepare.rs b/crates/registry-evidence-client/src/prepare.rs index a8c0cfcca..23a8d8166 100644 --- a/crates/registry-evidence-client/src/prepare.rs +++ b/crates/registry-evidence-client/src/prepare.rs @@ -26,14 +26,20 @@ use crate::{ }; /// Largest role set one request may carry, per the request contract. +/// +/// The refusal message spells this bound in words, and a test asserts the two +/// agree, so a changed constant cannot leave the message stating a rule the +/// client does not apply. The same holds for the two bounds below it. pub const MAXIMUM_SUBJECTS: usize = 8; /// Largest selector value set one subject may carry, per the request contract. pub const MAXIMUM_SELECTOR_VALUES: usize = 16; /// Largest expected output set a policy may state. A Version 1 requirement /// cannot publish more concepts than this. pub const MAXIMUM_EXPECTED_OUTPUTS: usize = 16; -const MAXIMUM_IDENTIFIER_BYTES: usize = 512; -const MAXIMUM_SELECTOR_STRING_BYTES: usize = 512; +/// Longest identifier any expectation may carry. +pub const MAXIMUM_IDENTIFIER_BYTES: usize = 512; +/// Longest string a selector value may carry. +pub const MAXIMUM_SELECTOR_STRING_BYTES: usize = 512; /// Smallest selector integer the request contract accepts. The bound is the /// range a double represents exactly, so the value survives every JSON reader /// between here and the source. @@ -299,18 +305,37 @@ fn validate(spec: &EvidenceRequestSpec) -> Result<(), EvidenceClientError> { // a JSON Schema `uri` implementation can disagree in either direction, and // either disagreement is a bug the adopter cannot work around. The // deployment's answer stands. - for identifier in [ - &spec.requirement, - &spec.audience, - &spec.evidence_type, - &spec.issued_by, - &spec.provided_by, - &spec.configuration_revision, + // + // Each field carries its own reason, because the fix is a different edit to + // the relying procedure in each case. + for (identifier, reason) in [ + ( + &spec.requirement, + "the requirement identifier must be present and bounded", + ), + ( + &spec.audience, + "the audience identifier must be present and bounded", + ), + ( + &spec.evidence_type, + "the evidence type identifier must be present and bounded", + ), + ( + &spec.issued_by, + "the issuer identifier must be present and bounded", + ), + ( + &spec.provided_by, + "the provider identifier must be present and bounded", + ), + ( + &spec.configuration_revision, + "the configuration revision identifier must be present and bounded", + ), ] { if identifier.is_empty() || identifier.len() > MAXIMUM_IDENTIFIER_BYTES { - return Err(EvidenceClientError::configuration( - "every requirement, audience, issuer, provider, type, and revision identifier must be present and bounded", - )); + return Err(EvidenceClientError::configuration(reason)); } } if !is_purpose(&spec.purpose) { @@ -741,6 +766,66 @@ mod tests { } } + /// Six identifiers share one rule, and a shared message would leave an + /// adopter to guess which of the six the request will not carry. Each names + /// itself. + #[test] + fn each_refused_identifier_names_itself() { + let cases: [Breakage; 6] = [ + ( + "the requirement identifier must be present and bounded", + Box::new(|spec| spec.requirement.clear()), + ), + ( + "the audience identifier must be present and bounded", + Box::new(|spec| spec.audience.clear()), + ), + ( + "the evidence type identifier must be present and bounded", + Box::new(|spec| spec.evidence_type.clear()), + ), + ( + "the issuer identifier must be present and bounded", + Box::new(|spec| spec.issued_by.clear()), + ), + ( + "the provider identifier must be present and bounded", + Box::new(|spec| spec.provided_by.clear()), + ), + ( + "the configuration revision identifier must be present and bounded", + Box::new(|spec| spec.configuration_revision.clear()), + ), + ]; + for (reason, break_it) in cases { + let mut spec = spec(); + break_it(&mut spec); + assert_eq!( + PreparedEvidenceRequest::new(spec).expect_err(reason), + EvidenceClientError::configuration(reason) + ); + } + } + + /// The refusals spell their bounds in words, which is the readable form for + /// an adopter. A constant that moved without its message would leave the + /// client stating a rule it does not apply. + #[test] + fn the_bounds_the_refusals_spell_are_the_bounds_that_apply() { + assert_eq!( + MAXIMUM_SUBJECTS, 8, + "a refusal says \"between one and eight subject roles\"" + ); + assert_eq!( + MAXIMUM_EXPECTED_OUTPUTS, 16, + "a refusal says \"between one and sixteen outputs\"" + ); + assert_eq!( + MAXIMUM_SELECTOR_VALUES, 16, + "a refusal says \"between one and sixteen of them\"" + ); + } + /// The contract's integer bounds are the ones a double can represent /// exactly, and both extremes are inside them. #[test] diff --git a/crates/registry-evidence-client/src/problem.rs b/crates/registry-evidence-client/src/problem.rs index c2a31b4ac..f9fc803c1 100644 --- a/crates/registry-evidence-client/src/problem.rs +++ b/crates/registry-evidence-client/src/problem.rs @@ -83,7 +83,7 @@ pub(crate) fn map_problem( /// caller reports as a protocol failure rather than as a refusal it can /// explain. fn parse_problem(media_type: Option<&str>, body: &[u8]) -> Option { - if media_type.map(essence) != Some(PROBLEM_MEDIA_TYPE.to_owned()) + if !media_type.is_some_and(|value| essence(value).eq_ignore_ascii_case(PROBLEM_MEDIA_TYPE)) || body.is_empty() || body.len() > MAXIMUM_PROBLEM_BYTES { @@ -96,14 +96,10 @@ fn parse_problem(media_type: Option<&str>, body: &[u8]) -> Option { Some(problem) } -/// The lowercase media type without parameters. -pub(crate) fn essence(value: &str) -> String { - value - .split(';') - .next() - .unwrap_or_default() - .trim() - .to_ascii_lowercase() +/// The media type without its parameters. Callers compare it case-insensitively, +/// as the media type grammar requires. +pub(crate) fn essence(value: &str) -> &str { + value.split(';').next().unwrap_or_default().trim() } /// The contract's codes are lowercase snake case. Anything else is refused @@ -425,13 +421,23 @@ mod tests { #[test] fn the_media_type_is_compared_without_its_parameters() { - let mapped = map_problem( - 403, - Some("application/problem+json; charset=utf-8"), - &problem_json(403, "not_authorized"), - None, - None, - ); - assert!(matches!(mapped, EvidenceClientError::Denied { .. })); + // The grammar makes the type itself case-insensitive, and a parameter is + // not part of it, so both of these are the contract's media type. + for media_type in [ + "application/problem+json; charset=utf-8", + "Application/Problem+JSON", + ] { + let mapped = map_problem( + 403, + Some(media_type), + &problem_json(403, "not_authorized"), + None, + None, + ); + assert!( + matches!(mapped, EvidenceClientError::Denied { .. }), + "{media_type}" + ); + } } } diff --git a/crates/registry-evidence-client/src/request.rs b/crates/registry-evidence-client/src/request.rs index 1d52e2173..54412a791 100644 --- a/crates/registry-evidence-client/src/request.rs +++ b/crates/registry-evidence-client/src/request.rs @@ -14,33 +14,39 @@ use serde::Serialize; /// One complete request body. /// +/// The wire types are crate-internal: a caller states what it wants in +/// [`crate::EvidenceRequestSpec`], and this is the serialization +/// [`crate::EvidenceClient::prepare`] derives from it. Only +/// [`SelectorValue`] is part of the public surface, because a caller supplies +/// the selector values themselves. +/// /// `Debug` is redacted: the selector values are the caller's own identifying /// input and must not reach a log line, a panic message, or a snapshot. #[derive(Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct EvidenceRequestBody { - pub request_nonce: String, - pub requirement: String, - pub purpose: String, +pub(crate) struct EvidenceRequestBody { + pub(crate) request_nonce: String, + pub(crate) requirement: String, + pub(crate) purpose: String, /// Unordered role set encoded as an array. Each configured role appears /// exactly once; array position carries no meaning. - pub subjects: Vec, + pub(crate) subjects: Vec, } #[derive(Clone, PartialEq, Eq, Serialize)] -pub struct RequestedSubject { - pub role: String, - pub selector: RequestedSelector, +pub(crate) struct RequestedSubject { + pub(crate) role: String, + pub(crate) selector: RequestedSelector, } #[derive(Clone, PartialEq, Eq, Serialize)] -pub struct RequestedSelector { - pub profile: String, +pub(crate) struct RequestedSelector { + pub(crate) profile: String, /// Values are present only for a selector profile whose values originate /// in the request. A profile that reads the authenticated context or an /// authenticated grant must carry none. #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option>, + pub(crate) values: Option>, } /// The three scalar shapes a selector value may take. diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index f7b937ccd..9ec3c450c 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -91,23 +91,19 @@ async fn first_use_acceptance_then_pinning_completes_two_verified_exchanges() { let proof: Result<_, Box> = async { let definitions = client.discover().await?; - let first = client - .prepare(spec( - &definitions, - "first-use", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); + let first = client.prepare(spec( + &definitions, + "first-use", + SubjectExpectations::AcceptFirstUse, + ))?; let accepted = client.request_and_verify(&first).await?; let pinned = accepted.pinned_subject_expectations(); - let second = client - .prepare(spec( - &definitions, - "first-use", - SubjectExpectations::Pinned(pinned.clone()), - )) - .unwrap(); + let second = client.prepare(spec( + &definitions, + "first-use", + SubjectExpectations::Pinned(pinned.clone()), + ))?; let repinned = client.request_and_verify(&second).await?; Ok(( accepted, @@ -171,24 +167,20 @@ async fn a_pinned_binding_refuses_an_assertion_about_another_subject() { let proof: Result<_, Box> = async { let definitions = client.discover().await?; - let known = client - .prepare(spec( - &definitions, - "known-subject", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); - let known = client.request_and_verify(&known).await?; + let request = client.prepare(spec( + &definitions, + "known-subject", + SubjectExpectations::AcceptFirstUse, + ))?; + let known = client.request_and_verify(&request).await?; // The same request shape, a different subject, and the first subject's // binding pinned. - let other = client - .prepare(spec( - &definitions, - "other-subject", - SubjectExpectations::Pinned(known.pinned_subject_expectations()), - )) - .unwrap(); + let other = client.prepare(spec( + &definitions, + "other-subject", + SubjectExpectations::Pinned(known.pinned_subject_expectations()), + ))?; Ok(( known.pinned_subject_expectations(), client.request_and_verify(&other).await, @@ -309,13 +301,11 @@ async fn a_credential_without_the_configured_tag_is_refused() { let proof: Result<_, Box> = async { let definitions = entitled.discover().await?; let visible = unentitled.discover().await?; - let prepared = unentitled - .prepare(spec( - &definitions, - "unentitled", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); + let prepared = unentitled.prepare(spec( + &definitions, + "unentitled", + SubjectExpectations::AcceptFirstUse, + ))?; Ok((visible, unentitled.request_and_verify(&prepared).await)) } .await; @@ -343,13 +333,11 @@ async fn a_request_the_deployment_cannot_answer_reports_no_evidence() { let proof: Result<_, Box> = async { let definitions = client.discover().await?; - let prepared = client - .prepare(spec( - &definitions, - "unresolved", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); + let prepared = client.prepare(spec( + &definitions, + "unresolved", + SubjectExpectations::AcceptFirstUse, + ))?; Ok(client.request_and_verify(&prepared).await) } .await; @@ -373,20 +361,16 @@ async fn a_response_cannot_verify_against_another_prepared_request() { let proof: Result<_, Box> = async { let definitions = client.discover().await?; - let sent = client - .prepare(spec( - &definitions, - "nonce-check", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); - let other = client - .prepare(spec( - &definitions, - "nonce-check", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); + let sent = client.prepare(spec( + &definitions, + "nonce-check", + SubjectExpectations::AcceptFirstUse, + ))?; + let other = client.prepare(spec( + &definitions, + "nonce-check", + SubjectExpectations::AcceptFirstUse, + ))?; let response = client.send(&sent).await?; Ok(( client.verify(&sent, &response), @@ -414,13 +398,11 @@ async fn a_response_under_the_wrong_media_type_is_refused() { let proof: Result<_, Box> = async { let definitions = client.discover().await?; - let prepared = client - .prepare(spec( - &definitions, - "media-type", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); + let prepared = client.prepare(spec( + &definitions, + "media-type", + SubjectExpectations::AcceptFirstUse, + ))?; let response = client.send(&prepared).await?; client.verify(&prepared, &response)?; @@ -437,22 +419,16 @@ async fn a_response_under_the_wrong_media_type_is_refused() { Url::parse(&replay.uri())?, Arc::new(StaticToken::new(deployment.token())?), deployment.runtime.jwks().clone(), - )) - .unwrap(); + ))?; // A prepared request is good for one send, and the one above is spent, // so the replay leg carries its own. The media type is refused before // anything about the request is compared to anything in the response. - let replayed_request = replayed - .prepare(spec( - &definitions, - "media-type", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); - // Held so the stub outlives the exchange it answers. - let refusal = replayed.send(&replayed_request).await; - drop(replay); - Ok(refusal) + let replayed_request = replayed.prepare(spec( + &definitions, + "media-type", + SubjectExpectations::AcceptFirstUse, + ))?; + Ok(replayed.send(&replayed_request).await) } .await; let refusal = proof.expect("the deployment answers and the stub replays"); @@ -476,13 +452,11 @@ async fn a_response_beyond_the_configured_bound_is_refused() { let proof: Result<_, Box> = async { let definitions = client.discover().await?; - let prepared = bounded - .prepare(spec( - &definitions, - "bounded", - SubjectExpectations::AcceptFirstUse, - )) - .unwrap(); + let prepared = bounded.prepare(spec( + &definitions, + "bounded", + SubjectExpectations::AcceptFirstUse, + ))?; Ok(bounded.send(&prepared).await) } .await; diff --git a/crates/registry-evidence-client/tests/platform_support.rs b/crates/registry-evidence-client/tests/platform_support.rs new file mode 100644 index 000000000..629a75aa0 --- /dev/null +++ b/crates/registry-evidence-client/tests/platform_support.rs @@ -0,0 +1,15 @@ +//! Which platforms the deployment-backed suite covers. +//! +//! `against_a_real_deployment.rs` writes a deployment project to disk and sets +//! Unix permission modes on it, because the runtime it starts refuses key +//! material and configuration that are group or world writable. The whole file is +//! therefore compiled only on Unix. Nothing else in this crate is +//! platform-specific: the unit suite drives the client against local stubs and +//! runs everywhere. + +/// Stated as a test so the gap appears in the test output on a platform the +/// deployment-backed suite cannot run on, instead of that suite silently +/// contributing nothing. +#[cfg(not(unix))] +#[test] +fn the_deployment_backed_suite_runs_only_on_unix() {} From 112647621426ba6ac2288482b75238a83a09e824 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 08:07:51 +0700 Subject: [PATCH 14/67] fix(evidence): close client diagnostic and test gaps Name the offending base URL when a validation case is expected to fail: `expect_err` is not a format macro, so the placeholder was printed literally. State in the `Transport` doc that the four causes arrive in one variant but stay distinguishable through `kind`, since the previous wording read as if they were indistinguishable. Cover the two gaps the tests left open: a PEM block whose body is outside the base64 alphabet, which is the only input that reaches the "not readable PEM" refusal, and a differently cased response media type on the success path, which shares its comparison with the problem contract. Check the base URL scheme before its path, so a URL that is wrong in both ways names the transport that cannot protect the credential rather than a path detail. That ordering is the only behavior change here. Warn in `verify_as_of` about the stale instant, which accepts an assertion whose validity interval has elapsed, and say that a live decision calls `verify`. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/client.rs | 49 +++++++++++++++++ crates/registry-evidence-client/src/config.rs | 55 ++++++++++++------- crates/registry-evidence-client/src/error.rs | 5 +- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index ced837c36..a98619d23 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -263,6 +263,14 @@ impl EvidenceClient { /// validity; it only asks whether the assertion would have been acceptable /// then. /// + /// A past instant is the direction that costs something. Naming the instant + /// the bytes arrived, or any other stale instant, accepts an assertion whose + /// validity interval has since elapsed: the question asked is whether it was + /// acceptable then, and the answer stays yes forever. A caller deciding + /// something now calls [`EvidenceClient::verify`], which judges the response + /// against the current clock. This variant is for re-verifying a response + /// already held, at an instant the caller can justify. + /// /// The parameter is a [`chrono::DateTime`], the same instant type the /// portable verifier's own policy takes. pub fn verify_as_of( @@ -960,6 +968,40 @@ mod tests { ); } + /// The media-type grammar makes the type itself case-insensitive and a + /// parameter no part of it. The problem contract is compared that way, and so + /// is the success path, which shares the comparison: a deployment or an + /// intermediary that spells the type differently or appends a charset is + /// still answering with the contract's media type. + #[tokio::test] + async fn the_response_media_type_is_compared_without_its_case_or_parameters() { + let fixture = signed_evidence(); + for media_type in [ + EVIDENCE_JWS_MEDIA_TYPE, + "Application/JOSE+JSON", + "application/jose+json; charset=utf-8", + ] { + let server = MockServer::start().await; + let client = client_for(&server.uri(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixture.sign(prepared.request_nonce()), media_type), + ) + .mount(&server) + .await; + + client + .send(&prepared) + .await + .unwrap_or_else(|error| panic!("{media_type} was refused: {error}")); + } + } + /// A body the problem contract does not cover leaves the client with nothing /// to say about the failure, which is exactly when the deployment's own /// identifier for the exchange matters. A header value outside the rule is @@ -1195,6 +1237,13 @@ mod tests { .to_vec(), "the outbound client options are not usable", ), + // A framed block whose body is outside the base64 alphabet fails + // while the bundle is being read, which is the one refusal that + // names the PEM itself. + ( + b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), + "the pinned certificate authority bundle is not readable PEM", + ), ] { assert_eq!( EvidenceClient::new( diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index 15f008e3f..f3729968e 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -127,25 +127,17 @@ impl EvidenceClientConfig { "the base URL must carry no credentials, query, or fragment", )); } - if let Some(mut segments) = self.base_url.path_segments().map(Iterator::peekable) { - // A single trailing separator is the ordinary way to write a - // deployment prefix, and `endpoint` drops it. Any other empty - // segment would put `//` in every request path, which the deployment - // answers with a confusing 404. - while let Some(segment) = segments.next() { - if segment.is_empty() && segments.peek().is_some() { - return Err(EvidenceClientError::configuration( - "the base URL path must carry no empty segment other than a trailing separator", - )); - } - } - } // A bearer credential in cleartext is only acceptable when it cannot // leave the host, which is the local development and tutorial case. The // accepted forms are the ones an adopter types: either loopback numeric // family, or the reserved name `localhost`. Any other name is refused, // because a name that happens to resolve to a loopback address is still // resolved off-host, and the answer can change. + // + // This is checked before the path, so a base URL that is wrong in both + // ways is reported for the transport it cannot protect the credential + // over rather than for a path detail the adopter would fix first and + // learn nothing from. let transport_protects_the_credential = match self.base_url.scheme() { "https" => true, "http" => self.base_url.host().is_some_and(|host| match host { @@ -160,6 +152,19 @@ impl EvidenceClientConfig { "the base URL must use HTTPS, or HTTP with a loopback host", )); } + if let Some(mut segments) = self.base_url.path_segments().map(Iterator::peekable) { + // A single trailing separator is the ordinary way to write a + // deployment prefix, and `endpoint` drops it. Any other empty + // segment would put `//` in every request path, which the deployment + // answers with a confusing 404. + while let Some(segment) = segments.next() { + if segment.is_empty() && segments.peek().is_some() { + return Err(EvidenceClientError::configuration( + "the base URL path must carry no empty segment other than a trailing separator", + )); + } + } + } // The pinned key set is the load-bearing decision, so it fails here // rather than once per request inside the verifier, where an empty set // looks to an adopter like a deployment fault. @@ -243,6 +248,9 @@ mod tests { } } + /// The transport is checked before the path, so a base URL that is wrong in + /// both ways names the transport. That is the fault an adopter has to fix + /// first, and fixing the path alone would leave the credential exposed. #[test] fn a_base_url_that_cannot_protect_the_credential_is_refused() { for base_url in [ @@ -255,10 +263,18 @@ mod tests { "http://127.0.0.2.nip.io:8080", "http://localhost.evidence.example.org", "ftp://evidence.example.org", + // Unusable transport and an unusable path at once. + "ftp://evidence.example.org//x", ] { - assert!( - config(base_url).validate().is_err(), - "{base_url} was accepted" + let Err(error) = config(base_url).validate() else { + panic!("{base_url} was accepted"); + }; + assert_eq!( + error, + EvidenceClientError::configuration( + "the base URL must use HTTPS, or HTTP with a loopback host" + ), + "{base_url}" ); } } @@ -288,10 +304,11 @@ mod tests { "https://evidence.example.org//registry", "https://evidence.example.org/registry//tenant", ] { + let Err(error) = config(base_url).validate() else { + panic!("{base_url} was accepted"); + }; assert_eq!( - config(base_url) - .validate() - .expect_err("{base_url} was accepted"), + error, EvidenceClientError::configuration( "the base URL path must carry no empty segment other than a trailing separator" ), diff --git a/crates/registry-evidence-client/src/error.rs b/crates/registry-evidence-client/src/error.rs index 3b6c5b153..733dd5ed7 100644 --- a/crates/registry-evidence-client/src/error.rs +++ b/crates/registry-evidence-client/src/error.rs @@ -28,7 +28,10 @@ pub enum EvidenceClientError { Token(#[from] TokenError), /// The exchange did not complete. Connection setup, TLS negotiation, a - /// timeout, and a truncated body all collapse here. + /// timeout, and a body that exceeded the configured bound all arrive in this + /// one variant, and [`TransportKind`] tells them apart: TLS negotiation is + /// reported as a connection failure, and the other three each have their own + /// kind. #[error("the Evidence request did not complete: {kind}")] Transport { kind: TransportKind }, From 114d538ba9a8d577ff5bc4b90c7ab03225d7b349 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 08:57:29 +0700 Subject: [PATCH 15/67] feat(evidence): acquire client tokens via private key JWT Add a PrivateKeyJwt token provider to the Evidence client SDK so a relying party can obtain its own bearer credential instead of being handed one. It is plain OAuth 2.0: the client_credentials grant with the private_key_jwt client authentication method of RFC 7523 section 2.2, carrying no claim, route, or vocabulary belonging to any particular authorization server. The outbound construction rules the Evidence request already followed now live in one internal module, so the token request cannot drift from them: rustls, no redirects, no ambient proxy, no transport retry, caller-set timeouts, pinned certificate authorities, and the same loopback-only exception for cleartext. Security-sensitive surfaces for review: - Client key handling. The signing key is held for the life of the provider, withheld from every Debug rendering, and never serialized or logged. An unusable key, an unprotected token endpoint, or an out-of-range lifetime is refused at construction rather than once per request. - Assertion signing. Each token request signs its own assertion with a fresh ULID identifier and a short lifetime, so a captured assertion is worth one attempt inside that window and a replay-checking server can refuse a repeat. The assertion is built in a scrubbed buffer, but the copy the HTTP client owns as the request body cannot be wiped, and neither can the intermediate signing buffers. Memory hygiene is therefore partial by construction, and what actually bounds a leaked assertion is that it is single use with a sixty-second default lifetime. The call site says so where the copy is made. - Token caching. A credential is reused until it has less life left than the refresh margin, and concurrent callers wait for one request rather than opening one each. A credential with no stated lifetime is not cached. A refusal reports the registered OAuth error code alone: the server's error_description is dropped where the body is parsed. Offline tests cover the claim set, the header, per-request identifiers, the cache boundary against a movable clock, single flight under concurrency, the refusal matrix, and redaction. The integration suite adds a real authorization server on its own loopback origin beside the real Evidence deployment, and proves the whole chain: acquisition, reuse inside the window, replacement inside the margin, and refusal of a client whose key was never registered. Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + crates/registry-evidence-client/Cargo.toml | 9 +- crates/registry-evidence-client/README.md | 16 +- crates/registry-evidence-client/src/client.rs | 150 +-- crates/registry-evidence-client/src/config.rs | 34 +- crates/registry-evidence-client/src/lib.rs | 26 +- .../registry-evidence-client/src/outbound.rs | 129 ++ .../src/private_key_jwt.rs | 1188 +++++++++++++++++ crates/registry-evidence-client/src/token.rs | 162 +++ .../tests/against_a_real_deployment.rs | 480 ++++++- 10 files changed, 2026 insertions(+), 170 deletions(-) create mode 100644 crates/registry-evidence-client/src/outbound.rs create mode 100644 crates/registry-evidence-client/src/private_key_jwt.rs diff --git a/Cargo.lock b/Cargo.lock index 4e59ff8f1..815444681 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5467,6 +5467,7 @@ dependencies = [ "getrandom 0.4.3", "registry-evidence", "registry-evidence-verifier", + "registry-mint", "registry-platform-crypto", "registry-platform-httputil", "reqwest 0.12.28", @@ -5475,6 +5476,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "ulid", "url", "wiremock", "zeroize", diff --git a/crates/registry-evidence-client/Cargo.toml b/crates/registry-evidence-client/Cargo.toml index 7604c8238..83dfb8260 100644 --- a/crates/registry-evidence-client/Cargo.toml +++ b/crates/registry-evidence-client/Cargo.toml @@ -17,18 +17,23 @@ base64.workspace = true chrono.workspace = true getrandom.workspace = true registry-evidence-verifier.workspace = true +registry-platform-crypto.workspace = true registry-platform-httputil.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +# The synchronization primitives the cached token provider needs. Holding a +# credential across an await rules out a blocking lock, and reqwest already +# brings this runtime into any build that reaches the network. +tokio.workspace = true +ulid.workspace = true url.workspace = true zeroize.workspace = true [dev-dependencies] ed25519-dalek.workspace = true registry-evidence.workspace = true -registry-platform-crypto.workspace = true +registry-mint.workspace = true tempfile.workspace = true -tokio.workspace = true wiremock.workspace = true diff --git a/crates/registry-evidence-client/README.md b/crates/registry-evidence-client/README.md index 474885754..0d210d452 100644 --- a/crates/registry-evidence-client/README.md +++ b/crates/registry-evidence-client/README.md @@ -21,6 +21,11 @@ judgement about a response is made by `registry-evidence-verifier`. out-of-band pinning workflow only. - `TokenProvider` and `StaticToken` for the bearer credential the deployment's resource-server authentication expects. +- `PrivateKeyJwt`: a `TokenProvider` that acquires that credential itself, with + the OAuth 2.0 `client_credentials` grant and the `private_key_jwt` client + authentication method of RFC 7523. It is plain OAuth against any authorization + server offering that grant, it caches what it is issued, and it replaces a + credential before the refresh margin rather than after expiry. - `EvidenceClient::verify_as_of`: the same verification at an instant the caller names, for re-verifying a response it retained. @@ -83,6 +88,13 @@ async fn accept( outbound header, and never placed in an error, a `Debug` rendering, or a log line. Response bytes and header values are withheld from diagnostics too; a failure carries the deployment's operation identifier for support correlation. +- A client signing key given to `PrivateKeyJwt` is held for the life of the + provider and never rendered, logged, or serialized. Each token request signs its + own single-use assertion with a fresh identifier, so a captured assertion is + worth one attempt within its short lifetime. A refused token request reports the + registered OAuth error code and nothing else: the server's `error_description` + is server-authored text about a failed authentication attempt, so it is dropped + where the response is parsed. - Every response is read under a caller-configured byte bound before it is parsed. - Redirects are not followed, and the proxy environment variables are ignored. A @@ -99,7 +111,9 @@ cargo test -p registry-evidence-client The integration suite starts a real Evidence deployment over loopback HTTP and drives the whole exchange through it, so discovery, the request contract, the problem contract, and verification are proven against the runtime rather than -against a stub. +against a stub. The credential-acquisition cases add a real authorization server +on its own loopback origin, so token acquisition, caching, and refusal are proven +against a server that enforces the grant. ## License diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index a98619d23..236ec5267 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -11,7 +11,7 @@ use registry_evidence_verifier::{ verifier::{verify_flattened_jws, ExpectedSubjectDocument}, EVIDENCE_JWS_MEDIA_TYPE, }; -use registry_platform_httputil::{read_bounded, BoundedReadError}; +use registry_platform_httputil::read_bounded; use reqwest::{ header::{HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}, Method, StatusCode, @@ -22,7 +22,8 @@ use zeroize::Zeroizing; use crate::{ config::EvidenceClientConfig, definitions::EvidenceDefinitionsDocument, - error::{EvidenceClientError, TransportKind}, + error::EvidenceClientError, + outbound::{self, OutboundOptions}, prepare::{EvidenceRequestSpec, PreparedEvidenceRequest, SubjectExpectations}, problem::{essence, map_problem, sanitized_operation}, request::EvidenceRequestBody, @@ -371,19 +372,10 @@ impl EvidenceClient { } Credential::None => request, }; - request.send().await.map_err(|error| { - let kind = if error.is_timeout() { - TransportKind::Timeout - } else if error.is_connect() { - // TLS negotiation failures arrive here too. Separating them - // would mean reading a transport error chain whose text this - // crate must not copy into a diagnostic. - TransportKind::Connect - } else { - TransportKind::Exchange - }; - EvidenceClientError::transport(kind) - }) + request + .send() + .await + .map_err(|error| EvidenceClientError::transport(outbound::send_failure_kind(&error))) } /// Read a successful response of exactly one media type, or map the @@ -426,7 +418,11 @@ impl EvidenceClient { } // An answer meant as a success has no status or code worth // reporting, only the reason its bytes never arrived. - Err(error) => return Err(EvidenceClientError::transport(read_failure_kind(&error))), + Err(error) => { + return Err(EvidenceClientError::transport(outbound::read_failure_kind( + &error, + ))) + } }; if !(200..300).contains(&status) { @@ -454,54 +450,18 @@ impl EvidenceClient { } } -/// Build the outbound client. +/// Build the outbound client from the pinned deployment options. fn build_client(config: &EvidenceClientConfig) -> Result { - let mut builder = reqwest::Client::builder() - .timeout(config.request_timeout) - .connect_timeout(config.connect_timeout) - // A redirect is not part of the response contract, and following one - // would present the relying party's credential to a host the integrator - // never configured, on the say-so of a response header. The answer is - // reported as it stands instead. - .redirect(reqwest::redirect::Policy::none()) - // The proxy environment variables are ignored deliberately. An ambient - // variable would otherwise route a credential through an intermediary the - // integrator did not choose, and terminate the TLS session the pinned - // certificate authorities were meant to authenticate. - .no_proxy() - // Select rustls explicitly. Cargo unifies reqwest's feature set across - // a whole build, so another crate enabling reqwest's native-tls feature - // must not silently change which TLS backend this client uses. - .use_rustls_tls() - // One prepared request is one exchange. A transport-level retry would - // resend a nonce the relying party's policy has already committed to - // and would duplicate an outbound call the caller did not ask for. - .retry(reqwest::retry::never()); - if let Some(user_agent) = &config.user_agent { - builder = builder.user_agent(user_agent.clone()); - } - if let Some(pem) = &config.trusted_root_certificates { - let certificates = reqwest::Certificate::from_pem_bundle(pem).map_err(|_| { - EvidenceClientError::configuration( - "the pinned certificate authority bundle is not readable PEM", - ) - })?; - if certificates.is_empty() { - return Err(EvidenceClientError::configuration( - "the pinned certificate authority bundle carries no certificate", - )); - } - for certificate in certificates { - builder = builder.add_root_certificate(certificate); - } - // Trust exactly what the integrator pinned. Leaving the platform store - // enabled would mean any of its authorities could also vouch for the - // deployment, which is the opposite of pinning. - builder = builder.tls_built_in_root_certs(false); - } - builder.build().map_err(|_| { - EvidenceClientError::configuration("the outbound client options are not usable") + outbound::build_client(OutboundOptions { + request_timeout: config.request_timeout, + connect_timeout: config.connect_timeout, + user_agent: config.user_agent.as_deref(), + trusted_root_certificates: config + .trusted_root_certificates + .as_ref() + .map(|pem| pem.as_slice()), }) + .map_err(EvidenceClientError::configuration) } fn serialize_request(body: &EvidenceRequestBody) -> Result, EvidenceClientError> { @@ -509,26 +469,6 @@ fn serialize_request(body: &EvidenceRequestBody) -> Result, EvidenceClie .map_err(|_| EvidenceClientError::configuration("the request body cannot be serialized")) } -/// Why a bounded read failed, in the terms the caller can act on. -/// -/// The distinction matters most for a timeout, which is the likely failure: the -/// configured total timeout runs until the body finishes, so an answer that -/// starts and stalls elapses here rather than at connection setup. No part of the -/// underlying error text is copied into the reported failure. -fn read_failure_kind(error: &BoundedReadError) -> TransportKind { - match error { - BoundedReadError::ContentLengthExceeded { .. } - | BoundedReadError::BodyTooLarge { .. } - | BoundedReadError::LengthOverflow => TransportKind::ResponseTooLarge, - BoundedReadError::Transport(error) if error.is_timeout() => TransportKind::Timeout, - // The reader's error type is open, so a variant this crate does not know - // yet becomes the coarse exchange failure. It must never become a claim - // about the response size, which is the one thing an adopter would act on - // by raising their own bound. - _ => TransportKind::Exchange, - } -} - /// Read the role-bound subject bindings out of a response that has not been /// verified. /// @@ -561,10 +501,12 @@ fn untrusted_subject_bindings(body: &[u8]) -> Vec { mod tests { use super::*; use crate::{ + error::TransportKind, fixtures::{ signed_evidence, SignedEvidenceFixture, AUDIENCE, CONCEPT, CONFIGURATION_REVISION, EVIDENCE_TYPE, ISSUED_BY, MAXIMUM_LIFETIME_SECONDS, PROVIDED_BY, PURPOSE, REQUIREMENT, }, + outbound::read_failure_kind, prepare::{EvidenceRequestSpec, SubjectRequest}, request::SelectorValue, token::StaticToken, @@ -1219,41 +1161,49 @@ mod tests { #[tokio::test] async fn unusable_pinned_certificate_material_is_refused_at_construction() { let fixture = signed_evidence(); - for (bundle, reason) in [ + for (bundle, reasons) in [ ( b"".to_vec(), - "the pinned certificate authority bundle carries no certificate", + &["the pinned certificate authority bundle carries no certificate"][..], ), ( b"not a certificate".to_vec(), - "the pinned certificate authority bundle carries no certificate", + &["the pinned certificate authority bundle carries no certificate"][..], ), - // The PEM framing is accepted and the content is rejected later, when - // the outbound client is built, so this one surfaces as the coarse - // options failure. It is still refused at construction, which is what - // keeps the platform store from quietly taking over. + // PEM framing over base64 that decodes to nothing a certificate parser + // accepts. Which layer refuses it depends on the TLS backend the + // build's feature resolution left enabled: one rejects the content + // while the bundle is read, another while the outbound client is + // built. Either way it is refused at construction, which is the + // property that keeps the platform store from quietly taking over. ( b"-----BEGIN CERTIFICATE-----\nnot base64 at all\n-----END CERTIFICATE-----\n" .to_vec(), - "the outbound client options are not usable", + &[ + "the pinned certificate authority bundle is not readable PEM", + "the outbound client options are not usable", + ][..], ), // A framed block whose body is outside the base64 alphabet fails // while the bundle is being read, which is the one refusal that // names the PEM itself. ( b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), - "the pinned certificate authority bundle is not readable PEM", + &["the pinned certificate authority bundle is not readable PEM"][..], ), ] { - assert_eq!( - EvidenceClient::new( - config_for("https://evidence.example.org", &fixture) - .with_trusted_root_certificates(bundle.clone()) - ) - .map(|_| ()) - .expect_err("unusable trust material is refused"), - EvidenceClientError::configuration(reason), - "{:?}", + let error = EvidenceClient::new( + config_for("https://evidence.example.org", &fixture) + .with_trusted_root_certificates(bundle.clone()), + ) + .map(|_| ()) + .expect_err("unusable trust material is refused"); + let EvidenceClientError::Configuration { reason } = error else { + panic!("unusable trust material is a configuration failure: {error}"); + }; + assert!( + reasons.contains(&reason), + "{:?} was refused as {reason}", String::from_utf8_lossy(&bundle) ); } diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index f3729968e..3a3568056 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -11,7 +11,9 @@ use registry_platform_httputil::DEFAULT_OUTBOUND_CONNECT_TIMEOUT; use url::Url; use zeroize::Zeroizing; -use crate::{error::EvidenceClientError, token::TokenProvider}; +use crate::{ + error::EvidenceClientError, outbound::transport_protects_the_credential, token::TokenProvider, +}; /// Longest response body the client will read. /// @@ -26,10 +28,6 @@ pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Time allowed for connection setup, including TLS negotiation. pub const DEFAULT_CONNECT_TIMEOUT: Duration = DEFAULT_OUTBOUND_CONNECT_TIMEOUT; -/// The one host name a cleartext base URL may carry. It is reserved for the -/// loopback interface, so a credential sent to it cannot leave the host. -const LOOPBACK_NAME: &str = "localhost"; - /// Everything the client needs, decided before the first request. pub struct EvidenceClientConfig { pub(crate) base_url: Url, @@ -127,27 +125,11 @@ impl EvidenceClientConfig { "the base URL must carry no credentials, query, or fragment", )); } - // A bearer credential in cleartext is only acceptable when it cannot - // leave the host, which is the local development and tutorial case. The - // accepted forms are the ones an adopter types: either loopback numeric - // family, or the reserved name `localhost`. Any other name is refused, - // because a name that happens to resolve to a loopback address is still - // resolved off-host, and the answer can change. - // - // This is checked before the path, so a base URL that is wrong in both - // ways is reported for the transport it cannot protect the credential - // over rather than for a path detail the adopter would fix first and - // learn nothing from. - let transport_protects_the_credential = match self.base_url.scheme() { - "https" => true, - "http" => self.base_url.host().is_some_and(|host| match host { - url::Host::Ipv4(ip) => ip.is_loopback(), - url::Host::Ipv6(ip) => ip.is_loopback(), - url::Host::Domain(name) => name == LOOPBACK_NAME, - }), - _ => false, - }; - if !transport_protects_the_credential { + // The transport is checked before the path, so a base URL that is wrong + // in both ways is reported for the transport it cannot protect the + // credential over rather than for a path detail the adopter would fix + // first and learn nothing from. + if !transport_protects_the_credential(&self.base_url) { return Err(EvidenceClientError::configuration( "the base URL must use HTTPS, or HTTP with a loopback host", )); diff --git a/crates/registry-evidence-client/src/lib.rs b/crates/registry-evidence-client/src/lib.rs index ebbbc855b..696ac3311 100644 --- a/crates/registry-evidence-client/src/lib.rs +++ b/crates/registry-evidence-client/src/lib.rs @@ -68,12 +68,14 @@ //! //! # What the async surface requires //! -//! Nothing in this crate names a runtime, and preparing and verifying are -//! synchronous. The HTTP methods, however, are `reqwest` calls, and `reqwest` -//! needs a tokio-compatible reactor to drive them, so an application that awaits -//! [`EvidenceClient::send`], [`EvidenceClient::request_and_verify`], -//! [`EvidenceClient::discover`], or [`EvidenceClient::fetch_jwks`] has to do so -//! on one. +//! Preparing and verifying are synchronous. The HTTP methods, however, are +//! `reqwest` calls, and `reqwest` needs a tokio-compatible reactor to drive them, +//! so an application that awaits [`EvidenceClient::send`], +//! [`EvidenceClient::request_and_verify`], [`EvidenceClient::discover`], or +//! [`EvidenceClient::fetch_jwks`] has to do so on one. [`PrivateKeyJwt`] runs +//! there too: it awaits a token endpoint, and it guards its cached credential +//! with tokio's asynchronous lock so a caller waiting for a token in flight does +//! not block the reactor thread. pub mod client; pub mod config; @@ -81,9 +83,15 @@ pub mod definitions; pub mod error; pub mod nonce; pub mod prepare; +pub mod private_key_jwt; pub mod request; pub mod token; +/// One rule set for every outbound exchange. Which rules apply to a credential +/// leaving the process is not a caller's choice, so the options and the client +/// construction stay internal. +mod outbound; + /// The closed problem contract is an internal parsing detail. What a caller acts /// on is the mapped failure in [`error`], never a problem body. mod problem; @@ -109,8 +117,12 @@ pub use prepare::{ MAXIMUM_SELECTOR_STRING_BYTES, MAXIMUM_SELECTOR_VALUES, MAXIMUM_SUBJECTS, MINIMUM_SELECTOR_INTEGER, }; +pub use private_key_jwt::{ + PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, + DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, +}; pub use request::SelectorValue; -pub use token::{BearerToken, StaticToken, TokenError, TokenProvider}; +pub use token::{BearerToken, OAuthErrorCode, StaticToken, TokenError, TokenProvider}; // The verification seam, re-exported so a relying party does not have to depend // on the verifier crate directly to name the types this API returns and accepts. diff --git a/crates/registry-evidence-client/src/outbound.rs b/crates/registry-evidence-client/src/outbound.rs new file mode 100644 index 000000000..a91b77205 --- /dev/null +++ b/crates/registry-evidence-client/src/outbound.rs @@ -0,0 +1,129 @@ +//! The one rule set every outbound exchange in this crate is built from. +//! +//! Two exchanges leave this crate: the Evidence request, which carries the +//! relying party's bearer credential, and the token request, which carries a +//! signed client assertion. Both hand a secret to a host the integrator named, +//! so both are built here rather than from two rule sets that could drift apart. + +use std::time::Duration; + +use registry_platform_httputil::BoundedReadError; +use url::Url; + +use crate::error::TransportKind; + +/// The one host name a cleartext URL may carry. It is reserved for the loopback +/// interface, so a credential sent to it cannot leave the host. +const LOOPBACK_NAME: &str = "localhost"; + +/// What a caller may vary about an outbound client. Everything else is fixed by +/// [`build_client`]. +#[derive(Debug, Clone, Copy)] +pub(crate) struct OutboundOptions<'a> { + pub(crate) request_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) user_agent: Option<&'a str>, + pub(crate) trusted_root_certificates: Option<&'a [u8]>, +} + +/// Build an outbound client. +/// +/// A failure is fixed text naming what about the options is unusable. Each +/// caller wraps it in its own error vocabulary, because the two exchanges report +/// configuration failures to the adopter under different types. +pub(crate) fn build_client(options: OutboundOptions<'_>) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(options.request_timeout) + .connect_timeout(options.connect_timeout) + // A redirect is not part of the response contract, and following one + // would present the relying party's credential to a host the integrator + // never configured, on the say-so of a response header. The answer is + // reported as it stands instead. + .redirect(reqwest::redirect::Policy::none()) + // The proxy environment variables are ignored deliberately. An ambient + // variable would otherwise route a credential through an intermediary the + // integrator did not choose, and terminate the TLS session the pinned + // certificate authorities were meant to authenticate. + .no_proxy() + // Select rustls explicitly. Cargo unifies reqwest's feature set across + // a whole build, so another crate enabling reqwest's native-tls feature + // must not silently change which TLS backend this client uses. + .use_rustls_tls() + // One prepared request is one exchange. A transport-level retry would + // resend a nonce the relying party's policy has already committed to + // and would duplicate an outbound call the caller did not ask for. + .retry(reqwest::retry::never()); + if let Some(user_agent) = options.user_agent { + builder = builder.user_agent(user_agent); + } + if let Some(pem) = options.trusted_root_certificates { + let certificates = reqwest::Certificate::from_pem_bundle(pem) + .map_err(|_| "the pinned certificate authority bundle is not readable PEM")?; + if certificates.is_empty() { + return Err("the pinned certificate authority bundle carries no certificate"); + } + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + // Trust exactly what the integrator pinned. Leaving the platform store + // enabled would mean any of its authorities could also vouch for the + // deployment, which is the opposite of pinning. + builder = builder.tls_built_in_root_certs(false); + } + builder + .build() + .map_err(|_| "the outbound client options are not usable") +} + +/// Whether this URL's transport keeps a secret sent to it away from the network. +/// +/// A secret in cleartext is only acceptable when it cannot leave the host, which +/// is the local development and tutorial case. The accepted forms are the ones an +/// adopter types: either loopback numeric family, or the reserved name +/// `localhost`. Any other name is refused, because a name that happens to resolve +/// to a loopback address is still resolved off-host, and the answer can change. +pub(crate) fn transport_protects_the_credential(url: &Url) -> bool { + match url.scheme() { + "https" => true, + "http" => url.host().is_some_and(|host| match host { + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + url::Host::Domain(name) => name == LOOPBACK_NAME, + }), + _ => false, + } +} + +/// Why a send failed, in the terms the caller can act on. +pub(crate) fn send_failure_kind(error: &reqwest::Error) -> TransportKind { + if error.is_timeout() { + TransportKind::Timeout + } else if error.is_connect() { + // TLS negotiation failures arrive here too. Separating them would mean + // reading a transport error chain whose text this crate must not copy + // into a diagnostic. + TransportKind::Connect + } else { + TransportKind::Exchange + } +} + +/// Why a bounded read failed, in the terms the caller can act on. +/// +/// The distinction matters most for a timeout, which is the likely failure: the +/// configured total timeout runs until the body finishes, so an answer that +/// starts and stalls elapses here rather than at connection setup. No part of the +/// underlying error text is copied into the reported failure. +pub(crate) fn read_failure_kind(error: &BoundedReadError) -> TransportKind { + match error { + BoundedReadError::ContentLengthExceeded { .. } + | BoundedReadError::BodyTooLarge { .. } + | BoundedReadError::LengthOverflow => TransportKind::ResponseTooLarge, + BoundedReadError::Transport(error) if error.is_timeout() => TransportKind::Timeout, + // The reader's error type is open, so a variant this crate does not know + // yet becomes the coarse exchange failure. It must never become a claim + // about the response size, which is the one thing an adopter would act on + // by raising their own bound. + _ => TransportKind::Exchange, + } +} diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs new file mode 100644 index 000000000..7a4f45ddf --- /dev/null +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -0,0 +1,1188 @@ +//! Token acquisition with a signed client assertion. +//! +//! This is the OAuth 2.0 `client_credentials` grant with the `private_key_jwt` +//! client authentication method of RFC 7523 section 2.2: the client proves who it +//! is by signing a short-lived assertion with a key only it holds, so no shared +//! secret ever leaves the process or sits in a deployment's configuration. +//! +//! It is plain OAuth. Nothing here knows which authorization server it is talking +//! to, and the provider carries no claim, route, or vocabulary belonging to any +//! particular issuer. Any server that accepts this grant and this authentication +//! method will do. +//! +//! # What is cached, and for how long +//! +//! An access token is reused until it has less life left than the refresh margin, +//! at which point the next caller acquires a replacement. The margin exists +//! because a credential that is valid when the request is built may have expired +//! by the time the deployment reads it. A server that states no lifetime has given +//! nothing to cache against, so each request acquires its own credential. + +use std::{fmt, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use registry_platform_crypto::{PrivateJwk, SigningAlgorithm}; +use registry_platform_httputil::read_bounded; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::sync::{Mutex, RwLock}; +use ulid::Ulid; +use url::Url; +use zeroize::Zeroizing; + +use crate::{ + config::{DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT}, + outbound::{self, transport_protects_the_credential, OutboundOptions}, + problem::essence, + token::{BearerToken, OAuthErrorCode, TokenError, TokenProvider}, +}; + +/// Lifetime of one client assertion, when the integrator states none. +/// +/// The assertion is presented once, immediately, to one endpoint. Seconds are +/// enough, and a short window is what limits what a captured assertion is worth. +pub const DEFAULT_ASSERTION_LIFETIME_SECONDS: i64 = 60; + +/// Longest assertion lifetime this provider will sign. +/// +/// Authorization servers bound what they accept, and a request signed outside +/// that bound is refused with a code that says nothing about the reason. +pub const MAXIMUM_ASSERTION_LIFETIME_SECONDS: i64 = 300; + +/// How much of an access token's remaining life is treated as already spent. +pub const DEFAULT_REFRESH_MARGIN_SECONDS: i64 = 30; + +/// The grant this provider asks for. The client authenticates as itself, on its +/// own behalf, which is the only grant an Evidence relying party needs. +const GRANT_TYPE: &str = "client_credentials"; + +/// The client authentication method of RFC 7523 section 2.2. +const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded"; +const JSON_MEDIA_TYPE: &str = "application/json"; + +/// The only token type an `Authorization: Bearer` request can present. Compared +/// without case, as RFC 6749 section 5.1 requires. +const BEARER_TOKEN_TYPE: &str = "bearer"; + +/// Longest token response this provider will read. A token response is a small +/// JSON object; anything larger is not one. +const MAXIMUM_TOKEN_RESPONSE_BYTES: u64 = 16 * 1024; + +/// The instant the provider reasons about. +/// +/// It exists so assertion claims and cache arithmetic are driven by one source a +/// test can move, rather than by two readings of the host clock. +pub(crate) trait Clock: Send + Sync { + fn unix_seconds(&self) -> i64; +} + +/// The host clock. +struct SystemClock; + +impl Clock for SystemClock { + fn unix_seconds(&self) -> i64 { + Utc::now().timestamp() + } +} + +/// What an integrator decides before the provider can authenticate. +pub struct PrivateKeyJwtConfig { + token_endpoint: Url, + client_id: String, + client_key: PrivateJwk, + audience: Option, + assertion_lifetime_seconds: i64, + refresh_margin_seconds: i64, + request_timeout: Duration, + connect_timeout: Duration, + user_agent: Option, + trusted_root_certificates: Option>>, +} + +impl PrivateKeyJwtConfig { + /// Authenticate as `client_id` at `token_endpoint`, signing with + /// `client_key`. + /// + /// `client_key` must be an Ed25519 key carrying a key identifier: the + /// identifier is how the authorization server selects the registered public + /// key to check the assertion against. + #[must_use] + pub fn new(token_endpoint: Url, client_id: impl Into, client_key: PrivateJwk) -> Self { + Self { + token_endpoint, + client_id: client_id.into(), + client_key, + audience: None, + assertion_lifetime_seconds: DEFAULT_ASSERTION_LIFETIME_SECONDS, + refresh_margin_seconds: DEFAULT_REFRESH_MARGIN_SECONDS, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + user_agent: None, + trusted_root_certificates: None, + } + } + + /// State the assertion audience the authorization server expects. + /// + /// The default is the token endpoint URL, which is what RFC 7523 section 3 + /// recommends. Set this only when the server published a different value: an + /// assertion whose audience the server does not recognize is refused as an + /// authentication failure, with no indication of which claim was wrong. + #[must_use] + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + #[must_use] + pub fn with_assertion_lifetime_seconds(mut self, seconds: i64) -> Self { + self.assertion_lifetime_seconds = seconds; + self + } + + /// Treat this much of an access token's remaining life as already spent. + #[must_use] + pub fn with_refresh_margin_seconds(mut self, seconds: i64) -> Self { + self.refresh_margin_seconds = seconds; + self + } + + #[must_use] + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + #[must_use] + pub fn with_connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = timeout; + self + } + + #[must_use] + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Trust exactly these PEM-encoded certificate authorities for the token + /// endpoint's TLS certificate, instead of the platform's own store. + #[must_use] + pub fn with_trusted_root_certificates(mut self, pem_bundle: impl Into>) -> Self { + self.trusted_root_certificates = Some(Zeroizing::new(pem_bundle.into())); + self + } +} + +impl fmt::Debug for PrivateKeyJwtConfig { + /// The client key and the pinned certificate material are withheld. Only the + /// operational choices and the public identifiers are rendered. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateKeyJwtConfig") + .field("token_endpoint", &self.token_endpoint.as_str()) + .field("client_id", &self.client_id) + .field("audience", &self.audience) + .field( + "assertion_lifetime_seconds", + &self.assertion_lifetime_seconds, + ) + .field("refresh_margin_seconds", &self.refresh_margin_seconds) + .field("request_timeout", &self.request_timeout) + .field("connect_timeout", &self.connect_timeout) + .field("user_agent", &self.user_agent) + .finish_non_exhaustive() + } +} + +/// An access token, and the instant it stops being worth presenting. +struct CachedToken { + token: BearerToken, + expires_at: i64, +} + +/// A [`TokenProvider`] that authenticates with a signed assertion and caches what +/// it is issued. +pub struct PrivateKeyJwt { + http: reqwest::Client, + token_endpoint: Url, + client_id: String, + audience: String, + assertion_lifetime_seconds: i64, + refresh_margin_seconds: i64, + client_key: PrivateJwk, + key_id: String, + clock: Arc, + /// The credential in hand, if it is still worth presenting. + cached: RwLock>, + /// Held for the length of one token request, so concurrent callers wait for + /// that request instead of opening one each. + refresh_lock: Mutex<()>, +} + +impl PrivateKeyJwt { + /// Refuse a configuration that cannot authenticate, cannot protect its + /// assertion in transit, or cannot sign at all. + /// + /// Every one of these would otherwise fail once per request, as an + /// authentication refusal whose code says nothing about which part was wrong. + pub fn new(config: PrivateKeyJwtConfig) -> Result { + Self::with_clock(config, Arc::new(SystemClock)) + } + + pub(crate) fn with_clock( + config: PrivateKeyJwtConfig, + clock: Arc, + ) -> Result { + let refuse = |reason: &'static str| TokenError::Configuration { reason }; + + if config.client_id.trim().is_empty() { + return Err(refuse("the client identifier must not be empty")); + } + if !config.token_endpoint.username().is_empty() + || config.token_endpoint.password().is_some() + || config.token_endpoint.fragment().is_some() + { + return Err(refuse( + "the token endpoint must carry no credentials or fragment", + )); + } + // The assertion authenticates the client, so it is as sensitive in transit + // as the access token it is exchanged for, and the same transport rule + // applies to both. + if !transport_protects_the_credential(&config.token_endpoint) { + return Err(refuse( + "the token endpoint must use HTTPS, or HTTP with a loopback host", + )); + } + if !matches!(config.client_key.algorithm(), Ok(SigningAlgorithm::EdDsa)) { + return Err(refuse("the client key must sign with EdDSA")); + } + let key_id = config + .client_key + .kid + .clone() + .filter(|kid| !kid.trim().is_empty()) + .ok_or_else(|| refuse("the client key must carry a key identifier"))?; + if !(1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&config.assertion_lifetime_seconds) { + return Err(refuse( + "the assertion lifetime must be within 1..=300 seconds", + )); + } + if config.refresh_margin_seconds < 0 { + return Err(refuse("the refresh margin must not be negative")); + } + if config.request_timeout.is_zero() || config.connect_timeout.is_zero() { + return Err(refuse("the timeouts must be greater than zero")); + } + + let http = outbound::build_client(OutboundOptions { + request_timeout: config.request_timeout, + connect_timeout: config.connect_timeout, + user_agent: config.user_agent.as_deref(), + trusted_root_certificates: config + .trusted_root_certificates + .as_ref() + .map(|pem| pem.as_slice()), + }) + .map_err(refuse)?; + + Ok(Self { + http, + audience: config + .audience + .unwrap_or_else(|| config.token_endpoint.as_str().to_owned()), + token_endpoint: config.token_endpoint, + client_id: config.client_id, + assertion_lifetime_seconds: config.assertion_lifetime_seconds, + refresh_margin_seconds: config.refresh_margin_seconds, + client_key: config.client_key, + key_id, + clock, + cached: RwLock::new(None), + refresh_lock: Mutex::new(()), + }) + } + + /// The cached credential, if it has more life left than the refresh margin. + async fn usable_cached_token(&self, now: i64) -> Option { + let cached = self.cached.read().await; + cached + .as_ref() + .filter(|entry| now.saturating_add(self.refresh_margin_seconds) < entry.expires_at) + .map(|entry| entry.token.clone()) + } + + /// One client assertion, valid from `now` for the configured lifetime. + fn sign_assertion(&self, now: i64) -> Result, TokenError> { + let header = json!({ + "alg": "EdDSA", + "typ": "JWT", + // The server selects the registered public key by this identifier. + "kid": self.key_id, + }); + let claims = json!({ + "iss": self.client_id, + "sub": self.client_id, + "aud": self.audience, + "iat": now, + "exp": now.saturating_add(self.assertion_lifetime_seconds), + // Every assertion is single use. A server that caches identifiers to + // refuse a replay needs each request to bring its own, so one is + // generated per request and never reused. + "jti": Ulid::new().to_string(), + }); + + let encode = |value: &Value| -> Result { + serde_json::to_vec(value) + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|_| TokenError::Configuration { + reason: "the client assertion cannot be serialized", + }) + }; + let signing_input = format!("{}.{}", encode(&header)?, encode(&claims)?); + // The algorithm was checked at construction, so this reports a key that + // parsed and named EdDSA yet cannot sign. It stays an explicit failure + // rather than a retry, because no later request would sign either. + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), &self.client_key) + .map_err(|_| TokenError::Configuration { + reason: "the client key cannot sign a client assertion", + })?; + Ok(Zeroizing::new(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature) + ))) + } + + /// Exchange one fresh assertion for an access token. + async fn acquire(&self, now: i64) -> Result { + let assertion = self.sign_assertion(now)?; + // The assertion is a credential, so it lives in a scrubbed buffer here. + // The body reqwest owns afterwards cannot be wiped, which is why the + // assertion is single use and its lifetime is bounded. + let body = Zeroizing::new( + url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", GRANT_TYPE) + .append_pair("client_assertion_type", CLIENT_ASSERTION_TYPE) + .append_pair("client_assertion", &assertion) + .finish(), + ); + + let response = self + .http + .post(self.token_endpoint.clone()) + .header(CONTENT_TYPE, FORM_MEDIA_TYPE) + .header(ACCEPT, JSON_MEDIA_TYPE) + .body(body.as_str().to_owned()) + .send() + .await + .map_err(|error| TokenError::Transport { + kind: outbound::send_failure_kind(&error), + })?; + + let status = response.status().as_u16(); + let media_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = match read_bounded(response, MAXIMUM_TOKEN_RESPONSE_BYTES).await { + // The response carries a credential, so the buffer it was read into is + // wiped when this exchange ends. + Ok(body) => Zeroizing::new(body), + // The status arrived before the body did, and for a refusal it is the + // whole of what this crate would have reported anyway. + Err(_) if !(200..300).contains(&status) => return Err(TokenError::Protocol { status }), + Err(error) => { + return Err(TokenError::Transport { + kind: outbound::read_failure_kind(&error), + }) + } + }; + + if !(200..300).contains(&status) { + return Err(declined(status, &body)); + } + if status != 200 + || !media_type + .as_deref() + .is_some_and(|value| essence(value).eq_ignore_ascii_case(JSON_MEDIA_TYPE)) + { + return Err(TokenError::Protocol { status }); + } + let Ok(issued) = serde_json::from_slice::(&body) else { + return Err(TokenError::Protocol { status }); + }; + if !issued.token_type.eq_ignore_ascii_case(BEARER_TOKEN_TYPE) { + return Err(TokenError::Protocol { status }); + } + Ok(AcquiredToken { + // Moved rather than copied, so the credential ends up in the buffer + // `BearerToken` wipes on drop. + token: BearerToken::new(issued.access_token)?, + // A stated lifetime is what makes caching possible. Without one, or + // with one already elapsed, the credential is used once and dropped. + expires_at: issued + .expires_in + .filter(|seconds| *seconds > 0) + .map(|seconds| now.saturating_add(seconds)), + }) + } +} + +#[async_trait] +impl TokenProvider for PrivateKeyJwt { + async fn bearer_token(&self) -> Result { + if let Some(token) = self.usable_cached_token(self.clock.unix_seconds()).await { + return Ok(token); + } + // One caller performs the token request while the others wait here, then + // find what it cached. A lock rather than a shared future keeps this + // readable, and the wait is bounded by the request timeout the integrator + // configured. The freshness check is repeated after the lock is taken, + // because the caller that held it has usually just cached a credential. + let _refreshing = self.refresh_lock.lock().await; + let now = self.clock.unix_seconds(); + if let Some(token) = self.usable_cached_token(now).await { + return Ok(token); + } + + let acquired = self.acquire(now).await?; + let mut cached = self.cached.write().await; + // An uncacheable credential clears the cache rather than leaving a stale + // entry behind it. + *cached = acquired.expires_at.map(|expires_at| CachedToken { + token: acquired.token.clone(), + expires_at, + }); + Ok(acquired.token) + } +} + +impl fmt::Debug for PrivateKeyJwt { + /// The client key and the cached credential are withheld. What is rendered is + /// what an operator needs to recognize which provider this is. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateKeyJwt") + .field("token_endpoint", &self.token_endpoint.as_str()) + .field("client_id", &self.client_id) + .field("audience", &self.audience) + .field( + "assertion_lifetime_seconds", + &self.assertion_lifetime_seconds, + ) + .field("refresh_margin_seconds", &self.refresh_margin_seconds) + .field("key_id", &self.key_id) + .finish_non_exhaustive() + } +} + +/// A credential and what may be assumed about how long it lasts. +struct AcquiredToken { + token: BearerToken, + expires_at: Option, +} + +/// The success response of RFC 6749 section 5.1, in the members this client uses. +#[derive(Deserialize)] +struct IssuedToken { + access_token: String, + token_type: String, + expires_in: Option, +} + +/// The error response of RFC 6749 section 5.2. +/// +/// Only the code is read. `error_description` and `error_uri` are server-authored +/// text about a failed authentication attempt, so they stay in the buffer this +/// exchange is about to drop. +#[derive(Deserialize)] +struct DeclinedToken { + error: String, +} + +/// Map a refused token request onto the code it reported. +/// +/// RFC 6749 section 5.2 puts a decision about the client at 400, and an +/// authentication failure at 401. Any other status is the server reporting +/// something about itself, which is not a statement this client can act on as a +/// refusal. +fn declined(status: u16, body: &[u8]) -> TokenError { + if !matches!(status, 400 | 401) { + return TokenError::Protocol { status }; + } + match serde_json::from_slice::(body) { + Ok(declined) => TokenError::Refused { + code: OAuthErrorCode::from_wire(&declined.error), + }, + Err(_) => TokenError::Protocol { status }, + } +} + +#[cfg(test)] +mod tests { + use std::{ + net::TcpListener, + sync::{ + atomic::{AtomicI64, Ordering}, + Arc, + }, + time::Duration, + }; + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use ed25519_dalek::SigningKey; + use registry_platform_crypto::{verify, PrivateJwk, PublicJwk}; + use serde_json::{json, Value}; + use url::Url; + use wiremock::{ + matchers::{body_string_contains, header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + use crate::{ + error::TransportKind, + token::{OAuthErrorCode, TokenError, TokenProvider}, + }; + + /// The instant the offline assertions in this module are centered on. + const NOW: i64 = 1_785_000_000; + const CLIENT_ID: &str = "urn:example:client:relying-party"; + const KEY_ID: &str = "client-key-2026-01"; + const TOKEN_PATH: &str = "/token"; + /// A credential text no server in this module ever varies, so a test can + /// assert on which credential a caller received. + const ISSUED_CREDENTIAL: &str = "issued-access-token"; + const TOKEN_LIFETIME_SECONDS: i64 = 300; + + /// A clock a test moves by hand, so cache arithmetic is asserted rather than + /// waited out. + struct TestClock(AtomicI64); + + impl TestClock { + fn new(now: i64) -> Self { + Self(AtomicI64::new(now)) + } + + fn set(&self, now: i64) { + self.0.store(now, Ordering::Relaxed); + } + } + + impl Clock for TestClock { + fn unix_seconds(&self) -> i64 { + self.0.load(Ordering::Relaxed) + } + } + + /// A fresh client key. Every key is generated here, so no test carries key + /// material in the tree. + fn client_key(key_id: Option<&str>) -> PrivateJwk { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("the test host supplies randomness"); + let key = SigningKey::from_bytes(&seed); + let mut document = json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(key.to_bytes()), + }); + if let Some(key_id) = key_id { + document["kid"] = json!(key_id); + } + PrivateJwk::parse(&document.to_string()).expect("the test key parses") + } + + fn endpoint(base: &str) -> Url { + format!("{base}{TOKEN_PATH}") + .parse() + .expect("the token endpoint parses") + } + + fn config(token_endpoint: Url, client_key: PrivateJwk) -> PrivateKeyJwtConfig { + PrivateKeyJwtConfig::new(token_endpoint, CLIENT_ID, client_key) + } + + /// A provider on a test clock, against a token endpoint that answers with one + /// credential. + fn provider(token_endpoint: Url, clock: &Arc) -> PrivateKeyJwt { + PrivateKeyJwt::with_clock( + config(token_endpoint, client_key(Some(KEY_ID))), + clock.clone(), + ) + .expect("the provider is usable as configured") + } + + /// The token response a compliant authorization server returns. + fn issued(expires_in: Option) -> ResponseTemplate { + let mut body = json!({ + "access_token": ISSUED_CREDENTIAL, + "token_type": "Bearer", + }); + if let Some(expires_in) = expires_in { + body["expires_in"] = json!(expires_in); + } + ResponseTemplate::new(200).set_body_json(body) + } + + async fn token_endpoint_serving(response: ResponseTemplate) -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(TOKEN_PATH)) + .and(header("content-type", "application/x-www-form-urlencoded")) + .respond_with(response) + .mount(&server) + .await; + server + } + + async fn token_requests(server: &MockServer) -> usize { + server + .received_requests() + .await + .expect("the mock server records its requests") + .len() + } + + fn parts(assertion: &str) -> (Value, Value, Vec) { + let segments: Vec<&str> = assertion.split('.').collect(); + assert_eq!(segments.len(), 3, "an assertion carries three segments"); + let decode = |segment: &str| { + let bytes = URL_SAFE_NO_PAD + .decode(segment) + .expect("the segment is base64url"); + serde_json::from_slice::(&bytes).expect("the segment carries JSON") + }; + let signature = URL_SAFE_NO_PAD + .decode(segments[2]) + .expect("the signature is base64url"); + (decode(segments[0]), decode(segments[1]), signature) + } + + fn signing_input(assertion: &str) -> &str { + let boundary = assertion + .rfind('.') + .expect("an assertion carries three segments"); + &assertion[..boundary] + } + + /// RFC 7523 section 2.2 fixes the claim set the token endpoint reads. The + /// header names the key so the server can select it without guessing. + #[test] + fn an_assertion_carries_the_claims_the_token_endpoint_requires() { + let key = client_key(Some(KEY_ID)); + let public: PublicJwk = key.public(); + let secret = key + .d + .clone() + .expect("the test key carries private material"); + let token_endpoint = endpoint("https://tokens.example.org"); + let clock = Arc::new(TestClock::new(NOW)); + let provider = + PrivateKeyJwt::with_clock(config(token_endpoint.clone(), key), clock.clone()) + .expect("the provider is usable as configured"); + + let assertion = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let (header, claims, signature) = parts(&assertion); + + assert_eq!(header, json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_ID})); + assert_eq!(claims["iss"], json!(CLIENT_ID)); + assert_eq!(claims["sub"], json!(CLIENT_ID)); + assert_eq!(claims["aud"], json!(token_endpoint.as_str())); + assert_eq!(claims["iat"], json!(NOW)); + assert_eq!( + claims["exp"], + json!(NOW + DEFAULT_ASSERTION_LIFETIME_SECONDS) + ); + assert_eq!( + claims["jti"] + .as_str() + .expect("the assertion carries a jti") + .len(), + 26, + "the jti is a ULID" + ); + let members: std::collections::BTreeSet<&str> = claims + .as_object() + .expect("the claims are an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + members, + ["aud", "exp", "iat", "iss", "jti", "sub"] + .into_iter() + .collect(), + "the assertion carries exactly the claims the profile fixes" + ); + verify(signing_input(&assertion).as_bytes(), &signature, &public) + .expect("the assertion verifies under the client key"); + assert!( + !assertion.contains(&secret), + "the assertion carries the private key" + ); + } + + /// A replay-checking token endpoint refuses a repeated `jti`, so a fresh one + /// per request is what makes a second token request possible at all. + #[test] + fn every_assertion_gets_its_own_jti() { + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint("https://tokens.example.org"), &clock); + + let first = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let second = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + + let (_, first_claims, _) = parts(&first); + let (_, second_claims, _) = parts(&second); + assert_ne!(first_claims["jti"], second_claims["jti"]); + assert_eq!(first_claims["iat"], second_claims["iat"]); + } + + /// A deployment whose token endpoint expects an audience of its own name says + /// so, and the default is the endpoint URL. + #[test] + fn the_assertion_audience_can_be_overridden() { + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_audience("https://tokens.example.org/"), + clock.clone(), + ) + .expect("the provider is usable as configured"); + + let assertion = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let (_, claims, _) = parts(&assertion); + assert_eq!(claims["aud"], json!("https://tokens.example.org/")); + } + + /// The request the token endpoint receives is the form-encoded grant the + /// profile fixes, and it carries the assertion rather than a secret. + #[tokio::test] + async fn the_token_request_states_the_grant_and_the_authentication_method() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(TOKEN_PATH)) + .and(header("content-type", "application/x-www-form-urlencoded")) + .and(header("accept", "application/json")) + .and(body_string_contains("grant_type=client_credentials")) + .and(body_string_contains( + "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer", + )) + .and(body_string_contains("client_assertion=")) + .respond_with(issued(Some(TOKEN_LIFETIME_SECONDS))) + .mount(&server) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let token = provider + .bearer_token() + .await + .expect("the token endpoint issued a credential"); + + assert_eq!(token.expose(), ISSUED_CREDENTIAL); + assert_eq!(token_requests(&server).await, 1); + } + + /// A credential is reused while it has more life left than the refresh margin, + /// and a caller arriving inside the margin gets a fresh one instead of a + /// credential that may expire in flight. + #[tokio::test] + async fn a_cached_credential_is_reused_until_the_refresh_margin() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + assert_eq!(token_requests(&server).await, 1); + + // Well inside the cached lifetime. + clock.set(NOW + TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS - 1); + provider + .bearer_token() + .await + .expect("the cached credential"); + assert_eq!( + token_requests(&server).await, + 1, + "a usable cached credential was discarded" + ); + + // The first instant inside the refresh margin. + clock.set(NOW + TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS); + provider + .bearer_token() + .await + .expect("a replacement credential"); + assert_eq!( + token_requests(&server).await, + 2, + "a credential inside the refresh margin was reused" + ); + } + + /// A server that states no lifetime has told the client nothing it may cache + /// against, so every request asks again rather than guessing a lifetime. + #[tokio::test] + async fn a_credential_without_a_stated_lifetime_is_not_cached() { + let server = token_endpoint_serving(issued(None)).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + provider.bearer_token().await.expect("a second credential"); + + assert_eq!(token_requests(&server).await, 2); + } + + /// Many callers starting at once must not each open a token request. The + /// first one performs it and the rest use what it cached. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_callers_make_one_token_request() { + let server = token_endpoint_serving( + issued(Some(TOKEN_LIFETIME_SECONDS)).set_delay(Duration::from_millis(50)), + ) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = Arc::new(provider(endpoint(&server.uri()), &clock)); + + let callers: Vec<_> = (0..20) + .map(|_| { + let provider = provider.clone(); + tokio::spawn(async move { provider.bearer_token().await }) + }) + .collect(); + for caller in callers { + let token = caller + .await + .expect("the caller task ran") + .expect("every caller received a credential"); + assert_eq!(token.expose(), ISSUED_CREDENTIAL); + } + + assert_eq!( + token_requests(&server).await, + 1, + "concurrent callers stampeded the token endpoint" + ); + } + + /// A declined request reports the registered code and nothing else. The + /// description is server-authored text about a failed authentication, so it + /// must not reach the caller's diagnostic. + #[tokio::test] + async fn a_declined_token_request_reports_only_the_registered_code() { + let cases = [ + ( + 400, + json!({"error": "invalid_request"}).to_string(), + TokenError::Refused { + code: OAuthErrorCode::InvalidRequest, + }, + ), + ( + 401, + json!({"error": "invalid_client", "error_description": "canary assertion detail"}) + .to_string(), + TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + }, + ), + ( + 400, + json!({"error": "canary_extension_code"}).to_string(), + TokenError::Refused { + code: OAuthErrorCode::Other, + }, + ), + ( + 400, + "canary not json at all".to_owned(), + TokenError::Protocol { status: 400 }, + ), + ( + 403, + json!({"error": "invalid_client"}).to_string(), + TokenError::Protocol { status: 403 }, + ), + ( + 500, + json!({"error": "server_error"}).to_string(), + TokenError::Protocol { status: 500 }, + ), + ]; + + for (status, body, expected) in cases { + let server = token_endpoint_serving( + ResponseTemplate::new(status) + .insert_header("content-type", "application/json") + .set_body_string(body.clone()), + ) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("the token endpoint declined"); + assert_eq!(error, expected, "status {status}"); + let rendered = error.to_string(); + assert!(!rendered.contains("canary"), "{rendered}"); + } + } + + /// An answer that is not a usable token response is a protocol failure, never + /// a credential. Each of these would otherwise become a request the deployment + /// refuses for a reason the adopter cannot see. + #[tokio::test] + async fn an_unusable_token_response_is_refused() { + let cases = [ + ( + "a success in the wrong media type", + ResponseTemplate::new(200) + .insert_header("content-type", "text/plain") + .set_body_string( + json!({"access_token": "canary", "token_type": "Bearer"}).to_string(), + ), + ), + ( + "a success carrying no credential", + ResponseTemplate::new(200).set_body_json(json!({"token_type": "Bearer"})), + ), + ( + "a credential the Evidence request cannot present", + ResponseTemplate::new(200).set_body_json( + json!({"access_token": "canary", "token_type": "mac", "expires_in": 300}), + ), + ), + ( + "a credential that is not header safe", + ResponseTemplate::new(200) + .set_body_json(json!({"access_token": "canary token", "token_type": "Bearer"})), + ), + ( + "an unreadable success body", + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string("{"), + ), + ( + "a redirect instead of an answer", + ResponseTemplate::new(302).insert_header("location", "https://elsewhere.invalid/"), + ), + ]; + + for (description, response) in cases { + let server = token_endpoint_serving(response).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("the answer is not a usable token response"); + let rendered = error.to_string(); + assert!(!rendered.contains("canary"), "{description}: {rendered}"); + assert!( + matches!( + error, + TokenError::Protocol { .. } | TokenError::Invalid { .. } + ), + "{description}: {error:?}" + ); + } + } + + /// A token endpoint that never answers is reported as a transport failure, so + /// a caller can tell an unreachable server from a refusal. + #[tokio::test] + async fn a_token_endpoint_that_cannot_be_reached_reports_a_transport_failure() { + // The port is reserved and released, so the connection attempt is refused + // rather than answered. + let reservation = + TcpListener::bind(("127.0.0.1", 0)).expect("a loopback port is available"); + let port = reservation + .local_addr() + .expect("the reservation has an address") + .port(); + drop(reservation); + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&format!("http://127.0.0.1:{port}")), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("nothing is listening"); + assert_eq!( + error, + TokenError::Transport { + kind: TransportKind::Connect + } + ); + } + + /// A provider that could not authenticate, could not protect its assertion in + /// transit, or could not sign at all fails at construction rather than once + /// per request. + #[test] + fn an_unusable_provider_configuration_is_refused() { + let cases: Vec<(&str, PrivateKeyJwtConfig)> = vec![ + ( + "the client identifier must not be empty", + PrivateKeyJwtConfig::new( + endpoint("https://tokens.example.org"), + " ", + client_key(Some(KEY_ID)), + ), + ), + ( + "the token endpoint must use HTTPS, or HTTP with a loopback host", + config( + endpoint("http://tokens.example.org"), + client_key(Some(KEY_ID)), + ), + ), + ( + "the token endpoint must carry no credentials or fragment", + config( + "https://client:canary@tokens.example.org/token" + .parse() + .expect("the endpoint parses"), + client_key(Some(KEY_ID)), + ), + ), + ( + "the token endpoint must carry no credentials or fragment", + config( + "https://tokens.example.org/token#canary" + .parse() + .expect("the endpoint parses"), + client_key(Some(KEY_ID)), + ), + ), + ( + "the client key must carry a key identifier", + config(endpoint("https://tokens.example.org"), client_key(None)), + ), + ( + "the client key must sign with EdDSA", + config( + endpoint("https://tokens.example.org"), + PrivateJwk::parse( + &json!({ + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "kid": KEY_ID, + "x": URL_SAFE_NO_PAD.encode([1u8; 32]), + "y": URL_SAFE_NO_PAD.encode([2u8; 32]), + "d": URL_SAFE_NO_PAD.encode([3u8; 32]), + }) + .to_string(), + ) + .expect("the key parses"), + ), + ), + ( + "the assertion lifetime must be within 1..=300 seconds", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_assertion_lifetime_seconds(0), + ), + ( + "the assertion lifetime must be within 1..=300 seconds", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_assertion_lifetime_seconds(MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1), + ), + ( + "the refresh margin must not be negative", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_refresh_margin_seconds(-1), + ), + ( + "the timeouts must be greater than zero", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_request_timeout(Duration::ZERO), + ), + ( + "the pinned certificate authority bundle carries no certificate", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_trusted_root_certificates(Vec::new()), + ), + ( + "the pinned certificate authority bundle is not readable PEM", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_trusted_root_certificates( + b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), + ), + ), + ]; + + for (reason, candidate) in cases { + let error = PrivateKeyJwt::new(candidate).expect_err(reason); + assert_eq!(error, TokenError::Configuration { reason }); + } + } + + /// A key, a cached credential, and an assertion are all secrets. None of them + /// may reach a rendering. + #[tokio::test] + async fn debug_output_never_carries_the_client_key_or_the_credential() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let key = client_key(Some(KEY_ID)); + let secret = key + .d + .clone() + .expect("the test key carries private material"); + let candidate = config(endpoint(&server.uri()), key); + let rendered = format!("{candidate:?}"); + assert!(!rendered.contains(&secret), "{rendered}"); + + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock(candidate, clock.clone()) + .expect("the provider is usable as configured"); + provider.bearer_token().await.expect("a credential"); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains(&secret), "{rendered}"); + assert!(!rendered.contains(ISSUED_CREDENTIAL), "{rendered}"); + assert!(rendered.contains(KEY_ID), "the key identifier is public"); + } +} diff --git a/crates/registry-evidence-client/src/token.rs b/crates/registry-evidence-client/src/token.rs index 48a4a5c9e..ffa8bd222 100644 --- a/crates/registry-evidence-client/src/token.rs +++ b/crates/registry-evidence-client/src/token.rs @@ -10,6 +10,8 @@ use async_trait::async_trait; use thiserror::Error; use zeroize::Zeroizing; +use crate::error::TransportKind; + /// Longest accepted credential. Access tokens are bounded well below this; the /// limit keeps a hostile provider from handing over an unbounded header. const MAXIMUM_TOKEN_BYTES: usize = 8 * 1024; @@ -101,6 +103,84 @@ pub enum TokenError { Unavailable, #[error("the bearer credential is not usable: {reason}")] Invalid { reason: &'static str }, + + /// The provider cannot be used as configured. The reason is fixed text + /// chosen by the provider, never caller data and never key material. + #[error("the token provider cannot be used as configured: {reason}")] + Configuration { reason: &'static str }, + + /// The exchange with the authorization server did not complete. + #[error("the token request did not complete: {kind}")] + Transport { kind: TransportKind }, + + /// The authorization server declined to issue a token. The registered error + /// code is the whole of what is reported. + #[error("the authorization server declined to issue a token: {code}")] + Refused { code: OAuthErrorCode }, + + /// The answer was not a token response this crate can use: an unexpected + /// status, an unexpected media type, an unreadable body, or a token type the + /// Evidence request cannot present. + #[error("the token response does not satisfy the OAuth 2.0 contract: status {status}")] + Protocol { status: u16 }, +} + +/// The OAuth 2.0 error code an authorization server returned. +/// +/// This code is all a refused token request reports. The accompanying +/// `error_description` is server-authored text about a failed authentication +/// attempt, so it is dropped where the body is parsed rather than carried into a +/// diagnostic, and the client assertion and the key that signed it are never part +/// of any of these values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum OAuthErrorCode { + InvalidRequest, + InvalidClient, + InvalidGrant, + UnauthorizedClient, + UnsupportedGrantType, + InvalidScope, + /// A code outside RFC 6749 section 5.2. The server's own spelling is + /// deliberately not kept: it is unbounded text from the failed exchange, and + /// the extension registry is open, so no closed variant could hold it. + Other, +} + +impl OAuthErrorCode { + /// The registered spelling, or a fixed name for a code from outside the + /// section 5.2 set. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::InvalidClient => "invalid_client", + Self::InvalidGrant => "invalid_grant", + Self::UnauthorizedClient => "unauthorized_client", + Self::UnsupportedGrantType => "unsupported_grant_type", + Self::InvalidScope => "invalid_scope", + Self::Other => "unregistered_error_code", + } + } + + /// Read a code off the wire, keeping only whether it is one this crate names. + pub(crate) fn from_wire(code: &str) -> Self { + match code { + "invalid_request" => Self::InvalidRequest, + "invalid_client" => Self::InvalidClient, + "invalid_grant" => Self::InvalidGrant, + "unauthorized_client" => Self::UnauthorizedClient, + "unsupported_grant_type" => Self::UnsupportedGrantType, + "invalid_scope" => Self::InvalidScope, + _ => Self::Other, + } + } +} + +impl fmt::Display for OAuthErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } } #[cfg(test)] @@ -137,6 +217,88 @@ mod tests { assert!(BearerToken::new("A".repeat(MAXIMUM_TOKEN_BYTES + 1)).is_err()); } + /// A refusal names the registered code and nothing else, and every acquisition + /// failure renders as its own sentence so a support conversation can start + /// from the message alone. + #[test] + fn acquisition_failures_render_their_own_fixed_text() { + let cases = [ + ( + TokenError::Configuration { + reason: "the client identifier must not be empty", + }, + "the token provider cannot be used as configured: the client identifier must not be empty", + ), + ( + TokenError::Transport { + kind: TransportKind::Connect, + }, + "the token request did not complete: connection setup failed", + ), + ( + TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + }, + "the authorization server declined to issue a token: invalid_client", + ), + ( + TokenError::Refused { + code: OAuthErrorCode::Other, + }, + "the authorization server declined to issue a token: unregistered_error_code", + ), + ( + TokenError::Protocol { status: 500 }, + "the token response does not satisfy the OAuth 2.0 contract: status 500", + ), + ]; + for (error, rendered) in &cases { + assert_eq!(&error.to_string(), rendered); + } + let renderings: std::collections::BTreeSet = + cases.iter().map(|(error, _)| error.to_string()).collect(); + assert_eq!( + renderings.len(), + cases.len(), + "two failures render the same text" + ); + } + + /// A server may spell a code however it likes. Only the registered set is + /// named, so an unbounded spelling cannot travel in the error. + #[test] + fn unregistered_error_codes_collapse_to_one_name() { + assert_eq!( + OAuthErrorCode::from_wire("invalid_request"), + OAuthErrorCode::InvalidRequest + ); + assert_eq!( + OAuthErrorCode::from_wire("invalid_client"), + OAuthErrorCode::InvalidClient + ); + assert_eq!( + OAuthErrorCode::from_wire("invalid_grant"), + OAuthErrorCode::InvalidGrant + ); + assert_eq!( + OAuthErrorCode::from_wire("unauthorized_client"), + OAuthErrorCode::UnauthorizedClient + ); + assert_eq!( + OAuthErrorCode::from_wire("unsupported_grant_type"), + OAuthErrorCode::UnsupportedGrantType + ); + assert_eq!( + OAuthErrorCode::from_wire("invalid_scope"), + OAuthErrorCode::InvalidScope + ); + for candidate in ["", "Invalid_Client", "canary_extension_code"] { + let code = OAuthErrorCode::from_wire(candidate); + assert_eq!(code, OAuthErrorCode::Other, "{candidate}"); + assert!(!code.to_string().contains("canary"), "{candidate}"); + } + } + #[test] fn debug_output_never_carries_the_credential() { let token = BearerToken::new("secret-canary-value").expect("the credential is accepted"); diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 9ec3c450c..c46380b26 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -15,6 +15,13 @@ //! `initialize` seam, which builds the deployed authenticator and therefore //! requires a loopback token issuer, which only the local profile permits. //! Nothing in the fixture names a source product. +//! +//! Most cases are their own token issuer: the suite publishes a key set and signs +//! the credentials it presents. The credential-acquisition cases instead run a +//! real authorization server on its own loopback origin and let the client's +//! provider authenticate to it with a signed client assertion, so acquisition, +//! caching, and refusal are proven against a server that enforces the grant +//! rather than against a stub of this crate's making. use std::{ error::Error, @@ -32,9 +39,13 @@ use registry_evidence::{runtime::EvidenceRuntime, server}; use registry_evidence_client::{ AssuranceProfile, ConceptForm, DefinitionCardinality, DefinitionKind, EvidenceClient, EvidenceClientConfig, EvidenceClientError, EvidenceDefinitionsDocument, EvidenceRequestSpec, - PublicValue, SelectorField, SelectorValue, SelectorValueOrigin, StaticToken, - SubjectExpectations, SubjectRequest, TransportKind, VerificationError, - EVIDENCE_DEFINITIONS_SCHEMA_V1, + OAuthErrorCode, PrivateKeyJwt, PrivateKeyJwtConfig, PublicValue, SelectorField, SelectorValue, + SelectorValueOrigin, StaticToken, SubjectExpectations, SubjectRequest, TokenError, + TokenProvider, TransportKind, VerificationError, EVIDENCE_DEFINITIONS_SCHEMA_V1, +}; +use registry_mint::{ + config::MintConfig, + server::{self as mint_server, MintService}, }; use registry_platform_crypto::{sign, PrivateJwk}; use serde_json::{json, Value}; @@ -56,6 +67,17 @@ const PRINCIPAL: &str = "client-suite-principal"; const AUTH_KEY_ID: &str = "client-suite-auth-key"; const SOURCE_BEARER: &str = "source-bearer-canary"; +/// The registered client the acquisition cases authenticate as, the key it +/// signs its assertions with, and the key the authorization server signs the +/// credentials it issues with. +const CLIENT_ID: &str = "client-suite-relying-party"; +const CLIENT_KEY_ID: &str = "client-suite-client-key"; +const ISSUER_KEY_ID: &str = "client-suite-issuer-key"; + +/// The shortest access token lifetime the authorization server accepts. The +/// refresh margin case needs a margin wider than a whole credential's life. +const ISSUED_TOKEN_LIFETIME_SECONDS: i64 = 60; + /// Prefix the runtime gives every published subject binding. const BINDING_PREFIX: &str = "urn:evidence:subject:v1_"; @@ -470,6 +492,157 @@ async fn a_response_beyond_the_configured_bound_is_refused() { ); } +/// The whole chain, with no credential this suite signed: the provider proves who +/// it is to a real authorization server with a signed assertion, the server issues +/// an access token, and the deployment accepts that token for an exchange whose +/// response verifies. +#[tokio::test] +async fn an_acquired_credential_completes_a_verified_exchange() { + let issuer = start_token_issuer().await; + let deployment = start_trusting(resolved_source_answer(), Some(&issuer.origin)).await; + let client = deployment.client_using(issuer.provider()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let prepared = client.prepare(spec( + &definitions, + "acquired-credential", + SubjectExpectations::AcceptFirstUse, + ))?; + Ok(client.request_and_verify(&prepared).await?) + } + .await; + let accepted = proof.expect("the acquired credential is accepted and the response verifies"); + + let evidence = accepted.evidence(); + assert_eq!(evidence.supports_requirement, REQUIREMENT); + assert_eq!(evidence.supported_values.len(), 1); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + ); + // The audience is the one the authorization server registered for this + // client, not one the request could choose: the deployment compares the two + // and refuses a mismatch. Reaching a verified response therefore proves the + // acquired credential carried the registered identity. + assert_eq!(evidence.audience, RELYING_AUDIENCE); + assert_eq!(evidence.subjects.len(), 1); + assert!(evidence.subjects[0].binding.starts_with(BINDING_PREFIX)); +} + +/// A credential is acquired once and reused while it has life left. Two whole +/// exchanges are four requests, and every one of them presents the one credential +/// the authorization server issued. +#[tokio::test] +async fn a_cached_credential_serves_every_request_inside_its_window() { + let issuer = start_token_issuer().await; + let deployment = start_trusting(resolved_source_answer(), Some(&issuer.origin)).await; + let client = deployment.client_using(issuer.provider()); + + let proof: Result<_, Box> = async { + let definitions = client.discover().await?; + let first = client.prepare(spec( + &definitions, + "cached-credential", + SubjectExpectations::AcceptFirstUse, + ))?; + let accepted = client.request_and_verify(&first).await?; + let again = client.discover().await?; + let second = client.prepare(spec( + &again, + "cached-credential", + SubjectExpectations::Pinned(accepted.pinned_subject_expectations()), + ))?; + client.request_and_verify(&second).await?; + Ok(()) + } + .await; + proof.expect("both exchanges are accepted and verify"); + + assert_eq!( + issuer.issued_credential_count(), + 1, + "four requests inside the cache window asked the authorization server once" + ); +} + +/// A credential with less life left than the refresh margin is replaced rather +/// than presented, and the replacement is one the deployment accepts. +/// +/// A margin wider than the issuer's whole token lifetime puts every credential +/// inside it as soon as it arrives, which is the state an expiring credential +/// reaches on its own. The boundary itself is driven against a movable clock in +/// the crate's own suite; what this proves is that acquiring again against a real +/// server yields a working credential rather than a stale or refused one. +#[tokio::test] +async fn a_credential_inside_the_refresh_margin_is_replaced() { + let issuer = start_token_issuer().await; + let deployment = start_trusting(resolved_source_answer(), Some(&issuer.origin)).await; + let provider = issuer.provider_with_refresh_margin(ISSUED_TOKEN_LIFETIME_SECONDS * 2); + let client = deployment.client_using(Arc::clone(&provider) as Arc); + + let proof: Result<_, Box> = async { + let first = provider.bearer_token().await?; + let second = provider.bearer_token().await?; + let definitions = client.discover().await?; + let prepared = client.prepare(spec( + &definitions, + "refreshed-credential", + SubjectExpectations::AcceptFirstUse, + ))?; + let accepted = client.request_and_verify(&prepared).await?; + Ok((first, second, accepted)) + } + .await; + let (first, second, accepted) = + proof.expect("each acquisition is accepted and the last response verifies"); + + // Two direct acquisitions, then one for discovery and one for the request: + // nothing was cacheable, so each of the four asked the server for its own. + assert_eq!(issuer.issued_credential_count(), 4); + assert_eq!( + accepted.evidence().supports_requirement, + REQUIREMENT, + "the replacement credential is one the deployment accepts" + ); + // Neither acquisition can be printed, which is what keeps a credential out of + // a test log as much as out of a production one. + assert_eq!(format!("{first:?}"), "BearerToken { .. }"); + assert_eq!(format!("{second:?}"), "BearerToken { .. }"); +} + +/// A client whose key the authorization server never registered acquires nothing, +/// the failure is the registered OAuth code, and no request reaches the +/// deployment. +#[tokio::test] +async fn an_unregistered_client_key_is_refused_without_detail() { + let issuer = start_token_issuer().await; + let deployment = start_trusting(resolved_source_answer(), Some(&issuer.origin)).await; + // The registered key identifier over key material the server has never seen, + // so the assertion is refused on its signature rather than for naming a key + // the server cannot find. + let unregistered = generate_key(CLIENT_KEY_ID); + let client = deployment.client_using(issuer.provider_signing_with(unregistered)); + + let refusal = client + .discover() + .await + .expect_err("a client the server cannot authenticate acquires no credential"); + + assert_eq!( + refusal, + EvidenceClientError::Token(TokenError::Refused { + code: OAuthErrorCode::InvalidClient + }), + ); + assert_eq!( + refusal.to_string(), + "the authorization server declined to issue a token: invalid_client", + "the refusal reports the registered code and nothing else" + ); + assert_eq!(issuer.issued_credential_count(), 0); +} + // --------------------------------------------------------------------------- // Request specifications // --------------------------------------------------------------------------- @@ -601,6 +774,17 @@ impl Deployment { self.build_client(access_token, Some(max_response_bytes)) } + /// A client that acquires its credential from a provider rather than + /// presenting one this suite signed. + fn client_using(&self, token_provider: Arc) -> EvidenceClient { + EvidenceClient::new(EvidenceClientConfig::new( + self.base_url.clone(), + token_provider, + self.runtime.jwks().clone(), + )) + .expect("the client configuration is usable") + } + fn build_client(&self, access_token: &str, max_response_bytes: Option) -> EvidenceClient { let mut config = EvidenceClientConfig::new( self.base_url.clone(), @@ -618,7 +802,11 @@ impl Deployment { self.token_with_tags(&[CONFIGURED_TAG]) } - /// A credential this issuer signed, carrying the requester tags given. + /// A credential this suite signed, carrying the requester tags given. + /// + /// The deployment accepts it only where the suite is the issuer it trusts. A + /// deployment pointed at an external authorization server publishes a key set + /// this key is not in, so its credentials come from that server instead. fn token_with_tags(&self, requester_tags: &[&str]) -> String { let now = Utc::now().timestamp(); let claims = json!({ @@ -660,22 +848,38 @@ impl Drop for Deployment { } /// Stage, seal, load, and serve one deployment whose fixed source answers with -/// `source_answer`. +/// `source_answer`, trusting this suite as its token issuer. async fn start(source_answer: Value) -> Deployment { + start_trusting(source_answer, None).await +} + +/// The same deployment, trusting the token issuer at `external_issuer` when one +/// is named. +/// +/// Without one the suite publishes its own key set beside the fixed source and +/// signs the credentials it presents. With one, the deployment fetches keys from +/// that origin and only credentials that server issued are accepted. +async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> Deployment { let source = MockServer::start().await; - let issuer = source.uri(); let auth_key = generate_key(AUTH_KEY_ID); + let issuer = match external_issuer { + Some(origin) => origin.to_owned(), + None => { + // The issuer's key set and the fixed source share this origin. Under + // the local assurance profile the authentication issuer must be a + // canonical loopback HTTP origin, which is exactly what a wiremock + // server publishes. + Mock::given(method("GET")) + .and(path("/.well-known/jwks.json")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"keys": [auth_key.public()]})), + ) + .mount(&source) + .await; + source.uri() + } + }; - // The issuer's key set and the fixed source share this origin. Under the - // local assurance profile the authentication issuer must be a canonical - // loopback HTTP origin, which is exactly what a wiremock server publishes. - Mock::given(method("GET")) - .and(path("/.well-known/jwks.json")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"keys": [auth_key.public()]})), - ) - .mount(&source) - .await; // Matched on method and path only. The request the adapter builds is the // runtime's own contract, proven by the runtime's suite; re-pinning it here // would test the fixture rather than this client. @@ -703,7 +907,7 @@ async fn start(source_answer: Value) -> Deployment { fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) .expect("the secret root is owner-only"); copy_tree(&fixture_root(), &bundle_root); - rewrite_for_local_profile(&bundle_root, &issuer); + rewrite_for_local_profile(&bundle_root, &source.uri(), &issuer); write_secret( &secret_root, @@ -753,27 +957,35 @@ async fn start(source_answer: Value) -> Deployment { server, _directory: directory, }; - await_readiness(&deployment).await; + await_readiness( + "the deployment", + deployment + .base_url + .join("ready") + .expect("the readiness URL resolves"), + &deployment.server, + ) + .await; deployment } -/// Wait until the deployment reports itself ready, or fail with the reason it -/// did not. -async fn await_readiness(deployment: &Deployment) { +/// Wait until the service reports itself ready, or fail with the reason it did +/// not. +async fn await_readiness( + label: &str, + ready: Url, + server: &tokio::task::JoinHandle>, +) { let probe = reqwest::Client::builder() .no_proxy() .timeout(Duration::from_secs(1)) .build() .expect("the readiness probe client builds"); - let ready = deployment - .base_url - .join("ready") - .expect("the readiness URL resolves"); tokio::time::timeout(Duration::from_secs(10), async { loop { assert!( - !deployment.server.is_finished(), - "the deployment stopped before it reported readiness" + !server.is_finished(), + "{label} stopped before it reported readiness" ); if probe .get(ready.clone()) @@ -787,7 +999,207 @@ async fn await_readiness(deployment: &Deployment) { } }) .await - .expect("the deployment reports readiness"); + .unwrap_or_else(|_| panic!("{label} reports readiness")); +} + +// --------------------------------------------------------------------------- +// The token issuer harness +// --------------------------------------------------------------------------- + +/// One real authorization server, issuing access tokens on loopback for the life +/// of one test. +/// +/// It is the reference issuer for this stack, driven here as an ordinary OAuth 2.0 +/// token endpoint: the provider under test carries nothing specific to it, and any +/// server accepting the `client_credentials` grant with the `private_key_jwt` +/// authentication method would serve. +struct TokenIssuer { + origin: String, + token_endpoint: Url, + /// The key the registered client signs its assertions with. + client_key: PrivateJwk, + /// The issuer's own audit chain, which is where a released credential is + /// recorded and therefore how this suite counts what it issued. + audit_path: PathBuf, + shutdown: Option>, + server: tokio::task::JoinHandle>, + /// Held so the deployment on disk outlives the service that reads it. + _directory: tempfile::TempDir, +} + +impl TokenIssuer { + /// A provider authenticating as the registered client with its own key. + fn provider(&self) -> Arc { + self.build_provider(self.client_key.clone(), None) + } + + /// The same provider, treating this much of a credential's life as spent. + fn provider_with_refresh_margin(&self, seconds: i64) -> Arc { + self.build_provider(self.client_key.clone(), Some(seconds)) + } + + /// The same provider, signing with the key given instead of the registered + /// one. + fn provider_signing_with(&self, client_key: PrivateJwk) -> Arc { + self.build_provider(client_key, None) + } + + fn build_provider( + &self, + client_key: PrivateJwk, + refresh_margin_seconds: Option, + ) -> Arc { + // The assertion audience is left to its default, which is the token + // endpoint URL. This server requires exactly that, so the default is what + // is under test. + let mut config = + PrivateKeyJwtConfig::new(self.token_endpoint.clone(), CLIENT_ID, client_key); + if let Some(seconds) = refresh_margin_seconds { + config = config.with_refresh_margin_seconds(seconds); + } + Arc::new(PrivateKeyJwt::new(config).expect("the provider configuration is usable")) + } + + /// How many credentials this server has released. + /// + /// The audit chain is written before a credential leaves the endpoint, so a + /// count taken after an exchange has settled includes every release that + /// exchange caused. + fn issued_credential_count(&self) -> usize { + let chain = + fs::read_to_string(&self.audit_path).expect("the issuer audit chain is readable"); + chain + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line).expect("every audit line is one JSON envelope") + }) + .filter(|envelope| envelope["record"]["decision"] == json!("issued")) + .count() + } +} + +impl Drop for TokenIssuer { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + // A drop cannot await the graceful stop, so the task is abandoned rather + // than joined. The temporary deployment it reads is removed with this + // struct, and the process ends with the test binary. + self.server.abort(); + } +} + +/// Author, load, and serve one authorization server with a single registered +/// client whose identity matches what the Evidence fixture entitles. +async fn start_token_issuer() -> TokenIssuer { + // Hold the allocation while the matching deployment is authored, and release + // it only immediately before the service binds it. The issuer identity is + // part of that deployment, so the port has to be known first. + let reservation = TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); + let port = reservation + .local_addr() + .expect("read the reserved address") + .port(); + let origin = format!("http://127.0.0.1:{port}"); + let token_endpoint = Url::parse(&format!("{origin}/token")).expect("the token endpoint parses"); + + let directory = tempfile::tempdir().expect("temporary issuer root"); + let root = directory.path(); + let secret_root = root.join("secrets"); + fs::create_dir(&secret_root).expect("create the issuer secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("the issuer secret root is owner-only"); + fs::create_dir(root.join("clients")).expect("create the client registry"); + write_secret(&secret_root, "signing.jwk", &private_jwk(ISSUER_KEY_ID)); + write_secret( + &secret_root, + "audit-hash-key", + "issuer-audit-secret-canary-32-bytes-minimum", + ); + + // The registered client carries the principal, the relying-party audience, and + // the requester tag. None of them is anything the client can ask for: the + // deployment reads them from the credential this server issues. + let client_key = generate_key(CLIENT_KEY_ID); + let public_key = + serde_json::to_string(&client_key.public()).expect("the public key serializes"); + fs::write( + root.join(format!("clients/{CLIENT_ID}.yaml")), + format!( + "clientId: {CLIENT_ID}\nprincipal: {PRINCIPAL}\nevidenceAudience: {RELYING_AUDIENCE}\nrequesterTags: [{CONFIGURED_TAG}]\nkeys: [{public_key}]\n" + ), + ) + .expect("write the client registration"); + + let config_path = root.join("mint.yaml"); + fs::write( + &config_path, + format!( + r#"version: 1 +validationMode: supervised-local-development +issuer: {origin} +listener: {{address: 127.0.0.1, port: {port}}} +signing: + algorithm: EdDSA + activeKeyId: {ISSUER_KEY_ID} + activeKeyFile: secrets/signing.jwk +audit: + path: audit/decisions.jsonl + hashKeyFile: secrets/audit-hash-key + hashKeyVersion: 1 +accessTokens: + audiences: [{TOKEN_AUDIENCE}] + lifetimeSeconds: {ISSUED_TOKEN_LIFETIME_SECONDS} + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: {token_endpoint} + algorithms: [EdDSA] +clients: + directory: clients +"# + ), + ) + .expect("write the issuer configuration"); + + let config = MintConfig::load(&config_path).expect("the staged issuer configuration is valid"); + let audit_path = config.audit.path.clone(); + let service = Arc::new( + MintService::load(config) + .await + .expect("the staged issuer loads"), + ); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + drop(reservation); + let server = tokio::spawn(async move { + mint_server::serve(service, async { + let _ = shutdown_rx.await; + }) + .await + }); + + let issuer = TokenIssuer { + origin, + token_endpoint, + client_key, + audit_path, + shutdown: Some(shutdown_tx), + server, + _directory: directory, + }; + await_readiness( + "the token issuer", + Url::parse(&format!("{}/ready", issuer.origin)).expect("the readiness URL parses"), + &issuer.server, + ) + .await; + issuer } fn fixture_root() -> PathBuf { @@ -795,13 +1207,13 @@ fn fixture_root() -> PathBuf { .join("../../products/evidence/fixtures/acceptance/adult-status") } -/// Point the staged bundle at this test's issuer and source, and lower it to the -/// local assurance profile. +/// Point the staged bundle at this test's source and token issuer, and lower it +/// to the local assurance profile. /// /// The local profile is what permits a loopback token issuer. Every other /// security decision in the bundle, including authentication, authorization, /// selector validation, subject binding, signing, and audit, is unchanged. -fn rewrite_for_local_profile(bundle_root: &Path, origin: &str) { +fn rewrite_for_local_profile(bundle_root: &Path, source_origin: &str, issuer_origin: &str) { let configuration_path = bundle_root.join("evidence.yaml"); let mut document = fs::read_to_string(&configuration_path).expect("the staged configuration is readable"); @@ -814,19 +1226,19 @@ fn rewrite_for_local_profile(bundle_root: &Path, origin: &str) { replace_exact( &mut document, "baseUrl: https://source.invalid", - &format!("baseUrl: {origin}"), + &format!("baseUrl: {source_origin}"), 1, ); replace_exact( &mut document, "issuer: https://identity.invalid", - &format!("issuer: {origin}"), + &format!("issuer: {issuer_origin}"), 1, ); replace_exact( &mut document, "jwksUri: https://identity.invalid/.well-known/jwks.json", - &format!("jwksUri: {origin}/.well-known/jwks.json"), + &format!("jwksUri: {issuer_origin}/.well-known/jwks.json"), 1, ); fs::write(&configuration_path, document).expect("the local configuration is written"); From 43f0cdd91a4e0a32719c0e3210f7813b40849246 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 09:08:15 +0700 Subject: [PATCH 16/67] docs(evidence): align the version one product shape with the shipped crates The Version 1 product shape section described a tree that no longer exists. Three distinct problems, kept apart here because they carry different weight. Made stale by the client library work on this branch: - The opening sentence said version one is one crate and one binary, and the listing placed verifier.rs under the runtime crate. Response verification now lives in the portable registry-evidence-verifier library. - The prohibition said "Do not create client, worker, adapter, policy, credential, or interoperability crates in version one", and this branch adds registry-evidence-client. The word client leaves that list. The sentence sits in a section that enumerates the runtime crate's own files, beside a sentence about Rhai adapters not becoming crates, so it reads as a ban on decomposing the runtime into a service-oriented crate constellation rather than a ban on shipping a relying-party library. It is reworded to say that, and the amendment names the two non-runtime crates and binds them: they sit outside the frozen Version 1 runtime contract, delegate every Evidence semantic decision to the runtime or to the portable verifier, and add no Evidence semantics of their own. products/evidence/AGENTS.md already states the same boundary for both crates. Already stale before this branch, corrected while the listing is open: - local_verification.rs and observability.rs were missing from src/, though both are production modules declared in lib.rs. - relay_shaped_source.rs was missing from tests/. - registry-evidencectl was absent from the shape entirely, although this document's own Definition of Done depends on evidencectl build, doctor, and fixtures run. Left alone deliberately: the Phase 1 line "Create the single crate and binary with typed domain models" describes creating the runtime and is still true, and "The binary exposes four commands" still describes the public surface because the local verification helpers are hidden subcommands. Test-only modules stay out of the listing, matching the existing convention that omitted runtime_tests.rs; the verifier crate's fixtures.rs is omitted for the same reason. Every path in the listing was checked against the tree. This edits an approved governance statement rather than a descriptive one, so it needs explicit review at merge. Signed-off-by: Jeremi Joslin --- products/evidence/IMPLEMENTATION.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 919acff4d..4d2e5c1fe 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -22,8 +22,10 @@ profiles and guarded extensions. ## Version 1 product shape -Version one is one crate and one binary. Product-owned contracts and fixtures -remain outside the crate: +Version one is one runtime crate and one binary. The runtime depends on one +portable library for response verification, adopter tooling sits beside it +outside the frozen runtime contract, and product-owned contracts and fixtures +remain outside every crate: ```text crates/registry-evidence/ @@ -36,8 +38,10 @@ crates/registry-evidence/ contracts.rs kernel.rs lib.rs + local_verification.rs main.rs model.rs + observability.rs problem.rs rate_limit.rs rhai_runtime.rs @@ -48,14 +52,23 @@ crates/registry-evidence/ signing.rs source.rs values.rs - verifier.rs tests/ cli.rs deployment_projects.rs + relay_shaped_source.rs source_contracts.rs selector_conformance.rs security_contract_traceability.rs live_sources.rs +crates/registry-evidence-verifier/ + src/ + contracts.rs + lib.rs + model.rs + sdjwt_vc.rs + verifier.rs +crates/registry-evidencectl/ +crates/registry-evidence-client/ products/evidence/ contracts/ fixtures/ @@ -73,9 +86,12 @@ evidence evaluate --fixture evidence verify --jws --jwks --policy ``` -Do not create client, worker, adapter, policy, credential, or interoperability -crates in version one. Rhai adapters and derivations are deployment-bundle -artifacts, not Rust crates. +Do not decompose the runtime into worker, adapter, policy, credential, or +interoperability crates in version one. Rhai adapters and derivations are +deployment-bundle artifacts, not Rust crates. The adopter tooling and the +relying-party client library named above sit outside the frozen Version 1 +runtime contract, delegate every Evidence semantic decision to the runtime or to +the portable verifier, and add no Evidence semantics of their own. Production code is source-product neutral. `src/`, production Cargo features, dependencies, public types, configuration schemas, routes, and CLI options From ebcdba35e1ca3c1112b4158acb6d3317fcf9de01 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 09:37:29 +0700 Subject: [PATCH 17/67] fix(evidence): give token acquisition failures a stable discriminant TokenError is non_exhaustive with six variants that only Display text told apart, so external bindings (Node/Python) had nothing stable to match on. Add TokenError::kind(), mirroring the existing style at EvidenceClientError::kind(): rendered text stays free to reword, the six kind names (unavailable, invalid_credential, configuration, transport, refused, protocol) are the contract. This touches an authentication error surface and needs explicit review; kind() only exposes the existing discriminant, it does not add or widen anything a caller could not already learn from matching on the variant itself, and it changes no error content, credential handling, or zeroization. Also, four smaller test-accuracy fixes: - Tie the assertion-lifetime refusal message to MAXIMUM_ASSERTION_LIFETIME_SECONDS with a const assert, so a future change to the constant fails the build instead of leaving the message wrong. - Narrow the one pinned-CA test row whose refusal reason depends on Cargo's feature unification across the workspace to assert only the Configuration variant; the other rows keep their exact-string assertions, and fail-closed construction stays proven for all rows. - Correct a comment claiming a refresh-margin test path was uncacheable; the credential is cached, the configured margin just makes it unusable immediately. - Narrow a test doc comment that claimed no request reaches the deployment on an unregistered client key. The test only proves the token issuer's own audit chain issued nothing; it does not observe the deployment's request count, so the comment now says that explicitly. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/client.rs | 27 ++++---- .../src/private_key_jwt.rs | 3 + crates/registry-evidence-client/src/token.rs | 62 +++++++++++++++++++ .../tests/against_a_real_deployment.rs | 15 +++-- 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index 236ec5267..e066d24af 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -1164,11 +1164,11 @@ mod tests { for (bundle, reasons) in [ ( b"".to_vec(), - &["the pinned certificate authority bundle carries no certificate"][..], + Some(&["the pinned certificate authority bundle carries no certificate"][..]), ), ( b"not a certificate".to_vec(), - &["the pinned certificate authority bundle carries no certificate"][..], + Some(&["the pinned certificate authority bundle carries no certificate"][..]), ), // PEM framing over base64 that decodes to nothing a certificate parser // accepts. Which layer refuses it depends on the TLS backend the @@ -1176,20 +1176,21 @@ mod tests { // while the bundle is read, another while the outbound client is // built. Either way it is refused at construction, which is the // property that keeps the platform store from quietly taking over. + // The reason is left unpinned for this one row: it is Cargo's + // feature unification across the workspace that picks the layer, + // not anything this crate controls, so only the variant is + // asserted. ( b"-----BEGIN CERTIFICATE-----\nnot base64 at all\n-----END CERTIFICATE-----\n" .to_vec(), - &[ - "the pinned certificate authority bundle is not readable PEM", - "the outbound client options are not usable", - ][..], + None, ), // A framed block whose body is outside the base64 alphabet fails // while the bundle is being read, which is the one refusal that // names the PEM itself. ( b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), - &["the pinned certificate authority bundle is not readable PEM"][..], + Some(&["the pinned certificate authority bundle is not readable PEM"][..]), ), ] { let error = EvidenceClient::new( @@ -1201,11 +1202,13 @@ mod tests { let EvidenceClientError::Configuration { reason } = error else { panic!("unusable trust material is a configuration failure: {error}"); }; - assert!( - reasons.contains(&reason), - "{:?} was refused as {reason}", - String::from_utf8_lossy(&bundle) - ); + if let Some(reasons) = reasons { + assert!( + reasons.contains(&reason), + "{:?} was refused as {reason}", + String::from_utf8_lossy(&bundle) + ); + } } } diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index 7a4f45ddf..98989c99f 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -269,6 +269,9 @@ impl PrivateKeyJwt { .clone() .filter(|kid| !kid.trim().is_empty()) .ok_or_else(|| refuse("the client key must carry a key identifier"))?; + // Ties the message below to the constant, so the constant cannot drift + // from the number the message states. + const _: () = assert!(MAXIMUM_ASSERTION_LIFETIME_SECONDS == 300); if !(1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&config.assertion_lifetime_seconds) { return Err(refuse( "the assertion lifetime must be within 1..=300 seconds", diff --git a/crates/registry-evidence-client/src/token.rs b/crates/registry-evidence-client/src/token.rs index ffa8bd222..c5395c536 100644 --- a/crates/registry-evidence-client/src/token.rs +++ b/crates/registry-evidence-client/src/token.rs @@ -125,6 +125,28 @@ pub enum TokenError { Protocol { status: u16 }, } +impl TokenError { + /// A stable, machine-readable name for which kind of token failure this is. + /// + /// It exists for callers that have to branch or aggregate without matching + /// an enum this crate may extend: a metric label, a structured log field, or + /// a language binding that carries the discriminant across a boundary. The + /// rendered message is for people and may be reworded; these names are part + /// of the crate's contract and will not be renamed. A variant added later + /// brings a new name rather than reusing one of these. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Unavailable => "unavailable", + Self::Invalid { .. } => "invalid_credential", + Self::Configuration { .. } => "configuration", + Self::Transport { .. } => "transport", + Self::Refused { .. } => "refused", + Self::Protocol { .. } => "protocol", + } + } +} + /// The OAuth 2.0 error code an authorization server returned. /// /// This code is all a refused token request reports. The accompanying @@ -264,6 +286,46 @@ mod tests { ); } + /// The discriminant is what a binding, a metric label, or a caller's own + /// branch reads, so every variant has one and no two share it. + #[test] + fn every_token_failure_reports_its_own_stable_kind() { + let cases = [ + (TokenError::Unavailable, "unavailable"), + ( + TokenError::Invalid { + reason: "a bearer credential must be non-empty and within the accepted length", + }, + "invalid_credential", + ), + ( + TokenError::Configuration { + reason: "the client identifier must not be empty", + }, + "configuration", + ), + ( + TokenError::Transport { + kind: TransportKind::Connect, + }, + "transport", + ), + ( + TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + }, + "refused", + ), + (TokenError::Protocol { status: 500 }, "protocol"), + ]; + for (error, kind) in &cases { + assert_eq!(error.kind(), *kind, "{error}"); + } + let kinds: std::collections::BTreeSet<&str> = + cases.iter().map(|(error, _)| error.kind()).collect(); + assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); + } + /// A server may spell a code however it likes. Only the registered set is /// named, so an unbounded spelling cannot travel in the error. #[test] diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index c46380b26..9ea1c4270 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -597,8 +597,11 @@ async fn a_credential_inside_the_refresh_margin_is_replaced() { let (first, second, accepted) = proof.expect("each acquisition is accepted and the last response verifies"); - // Two direct acquisitions, then one for discovery and one for the request: - // nothing was cacheable, so each of the four asked the server for its own. + // The issuer states a lifetime, so each acquisition is cached; the margin + // configured above is twice that lifetime, so a cached credential is + // already outside it the moment it lands. Two direct acquisitions, then + // one for discovery and one for the request: every one of the four finds + // the cache unusable and asks the server for its own. assert_eq!(issuer.issued_credential_count(), 4); assert_eq!( accepted.evidence().supports_requirement, @@ -611,9 +614,11 @@ async fn a_credential_inside_the_refresh_margin_is_replaced() { assert_eq!(format!("{second:?}"), "BearerToken { .. }"); } -/// A client whose key the authorization server never registered acquires nothing, -/// the failure is the registered OAuth code, and no request reaches the -/// deployment. +/// A client whose key the authorization server never registered acquires +/// nothing, and the failure is the registered OAuth code. This is proven +/// against the issuer's own audit chain, which records zero credentials +/// issued; it does not observe the Evidence deployment's own request count, +/// only that `discover` returns the token failure before it would reach one. #[tokio::test] async fn an_unregistered_client_key_is_refused_without_detail() { let issuer = start_token_issuer().await; From f9719f207c0bb73eb8f3fc44f10eeb132a8a762b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:03:16 +0700 Subject: [PATCH 18/67] fix(evidence): give every client failure a stable discriminant TokenError::kind() let bindings distinguish token failures without matching a #[non_exhaustive] enum. TransportKind and NonceError are also #[non_exhaustive] and had no equivalent, so a binding could not tell a connect failure from a timeout, or bad entropy from a noncanonical nonce, despite the crate's own rustdoc promising that TransportKind tells transport failures apart. VerificationError is not #[non_exhaustive], so a binding can match it directly, but it carried no stable string either: left alone, each binding would invent its own verification-failure name and the two would drift apart. All three now carry a kind() accessor in the same shape as TokenError::kind(), and a test that constructs every variant and asserts the kinds are pairwise distinct. Security review: kind() exposes only which closed variant an error is, the same information a caller already has from matching the variant where the type permits it. No remote-controlled text (a response body, a header, a credential) reaches any of these accessors; every arm returns a fixed string chosen in this crate. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/error.rs | 39 ++++++++++++++++ crates/registry-evidence-client/src/nonce.rs | 34 ++++++++++++++ .../src/verifier.rs | 46 +++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/crates/registry-evidence-client/src/error.rs b/crates/registry-evidence-client/src/error.rs index 733dd5ed7..32dcee7f7 100644 --- a/crates/registry-evidence-client/src/error.rs +++ b/crates/registry-evidence-client/src/error.rs @@ -90,6 +90,27 @@ pub enum TransportKind { ResponseTooLarge, } +impl TransportKind { + /// A stable, machine-readable name for which kind of transport failure + /// this is. + /// + /// It exists for callers that have to branch or aggregate without matching + /// an enum this crate may extend: a metric label, a structured log field, or + /// a language binding that carries the discriminant across a boundary. The + /// rendered message is for people and may be reworded; these names are part + /// of the crate's contract and will not be renamed. A variant added later + /// brings a new name rather than reusing one of these. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Connect => "connect", + Self::Timeout => "timeout", + Self::Exchange => "exchange", + Self::ResponseTooLarge => "response_too_large", + } + } +} + impl EvidenceClientError { pub(crate) fn configuration(reason: &'static str) -> Self { Self::Configuration { reason } @@ -217,4 +238,22 @@ mod tests { cases.iter().map(|(error, _)| error.kind()).collect(); assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); } + + /// The discriminant is what a binding, a metric label, or a caller's own + /// branch reads, so every variant has one and no two share it. + #[test] + fn every_transport_kind_reports_its_own_stable_kind() { + let cases = [ + (TransportKind::Connect, "connect"), + (TransportKind::Timeout, "timeout"), + (TransportKind::Exchange, "exchange"), + (TransportKind::ResponseTooLarge, "response_too_large"), + ]; + for (kind, name) in &cases { + assert_eq!(kind.kind(), *name, "{kind}"); + } + let kinds: std::collections::BTreeSet<&str> = + cases.iter().map(|(kind, _)| kind.kind()).collect(); + assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); + } } diff --git a/crates/registry-evidence-client/src/nonce.rs b/crates/registry-evidence-client/src/nonce.rs index 0025718dc..cd56b7af7 100644 --- a/crates/registry-evidence-client/src/nonce.rs +++ b/crates/registry-evidence-client/src/nonce.rs @@ -75,6 +75,24 @@ pub enum NonceError { NotCanonical, } +impl NonceError { + /// A stable, machine-readable name for which kind of nonce failure this is. + /// + /// It exists for callers that have to branch or aggregate without matching + /// an enum this crate may extend: a metric label, a structured log field, or + /// a language binding that carries the discriminant across a boundary. The + /// rendered message is for people and may be reworded; these names are part + /// of the crate's contract and will not be renamed. A variant added later + /// brings a new name rather than reusing one of these. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Entropy => "entropy", + Self::NotCanonical => "not_canonical", + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -136,4 +154,20 @@ mod tests { // obligation this type cannot check. assert!(is_canonical("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); } + + /// The discriminant is what a binding, a metric label, or a caller's own + /// branch reads, so every variant has one and no two share it. + #[test] + fn every_nonce_failure_reports_its_own_stable_kind() { + let cases = [ + (NonceError::Entropy, "entropy"), + (NonceError::NotCanonical, "not_canonical"), + ]; + for (error, kind) in &cases { + assert_eq!(error.kind(), *kind, "{error}"); + } + let kinds: std::collections::BTreeSet<&str> = + cases.iter().map(|(error, _)| error.kind()).collect(); + assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); + } } diff --git a/crates/registry-evidence-verifier/src/verifier.rs b/crates/registry-evidence-verifier/src/verifier.rs index d599154d5..277d59b3d 100644 --- a/crates/registry-evidence-verifier/src/verifier.rs +++ b/crates/registry-evidence-verifier/src/verifier.rs @@ -336,6 +336,31 @@ pub enum VerificationError { Disclosure, } +impl VerificationError { + /// A stable, machine-readable name for which kind of verification failure + /// this is. + /// + /// It exists for callers that want to branch or aggregate on the failure + /// without matching this crate's enum directly: a metric label, a + /// structured log field, or a language binding that carries the + /// discriminant across a boundary a Rust enum cannot cross. The rendered + /// message is for people and may be reworded; these names are part of the + /// crate's contract and will not be renamed. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::MalformedJws => "malformed_jws", + Self::ProtectedHeader => "protected_header", + Self::Key => "key", + Self::Signature => "signature", + Self::Payload => "payload", + Self::Policy => "policy", + Self::Time => "time", + Self::Disclosure => "disclosure", + } + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ProtectedHeader { @@ -1973,4 +1998,25 @@ mod tests { Err(VerificationError::MalformedJws) ); } + + /// The discriminant is what a binding, a metric label, or a caller's own + /// branch reads, so every variant has one and no two share it. + #[test] + fn every_verification_failure_reports_its_own_stable_kind() { + let cases = [ + (VerificationError::MalformedJws, "malformed_jws"), + (VerificationError::ProtectedHeader, "protected_header"), + (VerificationError::Key, "key"), + (VerificationError::Signature, "signature"), + (VerificationError::Payload, "payload"), + (VerificationError::Policy, "policy"), + (VerificationError::Time, "time"), + (VerificationError::Disclosure, "disclosure"), + ]; + for (error, kind) in &cases { + assert_eq!(error.kind(), *kind, "{error}"); + } + let kinds: BTreeSet<&str> = cases.iter().map(|(error, _)| error.kind()).collect(); + assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); + } } From ba9e013ec998670c4178791e831249401fb578b2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:18:31 +0700 Subject: [PATCH 19/67] fix(evidence): bound the cached credential lifetime An authorization server's expires_in was cached without an upper bound, so a value such as i64::MAX kept a credential in memory for the life of the process with no way for the integrator to evict it. Clamp it to MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS (86400 seconds) before it reaches the cache arithmetic; re-acquiring earlier than an issuer's stated lifetime requires is always safe. The refusal parser also read any 400 or 401 body as JSON regardless of its announced media type, so an intermediary returning a non-JSON error body could be misread as a registered OAuth refusal code. declined() now requires the same media type the request asked for and falls through to a protocol failure otherwise. This changes which error an adopter sees for a proxy or gateway failure; it never widens what a refusal discloses. Also adds a test pinning that dropping an acquisition future while it holds the refresh lock does not leave the lock held: tokio's async mutex is not poisoned on a guard drop, so the next caller still completes. Security review: the expires_in clamp only shortens how long a remote- controlled value can keep a credential cached; it cannot lengthen a deployment's actual token lifetime or expose the credential differently. The media type gate only changes which TokenError variant a non-JSON 400/401 body maps to (Protocol instead of Refused); it does not change what is read from the body or disclosed to the caller in either case. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/lib.rs | 1 + .../src/private_key_jwt.rs | 135 +++++++++++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/crates/registry-evidence-client/src/lib.rs b/crates/registry-evidence-client/src/lib.rs index 696ac3311..7d98d128c 100644 --- a/crates/registry-evidence-client/src/lib.rs +++ b/crates/registry-evidence-client/src/lib.rs @@ -120,6 +120,7 @@ pub use prepare::{ pub use private_key_jwt::{ PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, }; pub use request::SelectorValue; pub use token::{BearerToken, OAuthErrorCode, StaticToken, TokenError, TokenProvider}; diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index 98989c99f..124356478 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -55,6 +55,20 @@ pub const MAXIMUM_ASSERTION_LIFETIME_SECONDS: i64 = 300; /// How much of an access token's remaining life is treated as already spent. pub const DEFAULT_REFRESH_MARGIN_SECONDS: i64 = 30; +/// Longest an issuer's stated `expires_in` is trusted for, when deciding how +/// long to cache the credential it came with. +/// +/// `expires_in` is a remote-controlled value. An authorization server that +/// reports one far longer than any real access token lives, whether by a bug +/// or by intent, must not be able to keep a credential cached, and therefore +/// live in memory, for the life of the process with no way for the integrator +/// to evict it. Re-acquiring a token earlier than an issuer's stated lifetime +/// requires is always safe, so clamping to 86400 seconds (24 hours) cannot +/// break a correct deployment. +pub const MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS: i64 = 86_400; +// Ties the doc comment above to the constant, so the two cannot drift apart. +const _: () = assert!(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS == 86_400); + /// The grant this provider asks for. The client authenticates as itself, on its /// own behalf, which is the only grant an Evidence relying party needs. const GRANT_TYPE: &str = "client_credentials"; @@ -409,7 +423,7 @@ impl PrivateKeyJwt { }; if !(200..300).contains(&status) { - return Err(declined(status, &body)); + return Err(declined(status, media_type.as_deref(), &body)); } if status != 200 || !media_type @@ -430,9 +444,12 @@ impl PrivateKeyJwt { token: BearerToken::new(issued.access_token)?, // A stated lifetime is what makes caching possible. Without one, or // with one already elapsed, the credential is used once and dropped. + // A lifetime longer than this provider will trust is clamped before + // it ever reaches the cache arithmetic below. expires_at: issued .expires_in .filter(|seconds| *seconds > 0) + .map(|seconds| seconds.min(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS)) .map(|seconds| now.saturating_add(seconds)), }) } @@ -515,9 +532,14 @@ struct DeclinedToken { /// RFC 6749 section 5.2 puts a decision about the client at 400, and an /// authentication failure at 401. Any other status is the server reporting /// something about itself, which is not a statement this client can act on as a -/// refusal. -fn declined(status: u16, body: &[u8]) -> TokenError { - if !matches!(status, 400 | 401) { +/// refusal. A body is read as a refusal only when it arrives in the media type +/// the request asked for; an intermediary answering in some other media type, +/// or none at all, never reached the authorization server's own refusal logic, +/// so it is reported as a protocol failure instead. +fn declined(status: u16, media_type: Option<&str>, body: &[u8]) -> TokenError { + if !matches!(status, 400 | 401) + || !media_type.is_some_and(|value| essence(value).eq_ignore_ascii_case(JSON_MEDIA_TYPE)) + { return TokenError::Protocol { status }; } match serde_json::from_slice::(body) { @@ -844,6 +866,44 @@ mod tests { ); } + /// A stated lifetime the issuer never bounded, such as `i64::MAX`, must not + /// keep a credential cached for the life of the process. The provider clamps + /// it to its own configured maximum before caching. + #[tokio::test] + async fn an_unbounded_stated_lifetime_is_clamped_to_the_configured_maximum() { + let server = token_endpoint_serving(issued(Some(i64::MAX))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + assert_eq!(token_requests(&server).await, 1); + + // Well inside the clamped lifetime, despite the issuer stating an + // effectively unbounded one. + clock.set(NOW + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS - 1); + provider + .bearer_token() + .await + .expect("the cached credential"); + assert_eq!( + token_requests(&server).await, + 1, + "a usable cached credential was discarded" + ); + + // The first instant inside the refresh margin of the clamped lifetime. + clock.set(NOW + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS); + provider + .bearer_token() + .await + .expect("a replacement credential"); + assert_eq!( + token_requests(&server).await, + 2, + "an unbounded stated lifetime was cached past the configured maximum" + ); + } + /// A server that states no lifetime has told the client nothing it may cache /// against, so every request asks again rather than guessing a lifetime. #[tokio::test] @@ -890,6 +950,35 @@ mod tests { ); } + /// Tokio's asynchronous mutex is not poisoned when a guard is dropped + /// mid-await, unlike `std::sync::Mutex`. A caller abandoned while it holds + /// the refresh lock, whether by cancellation or a panic elsewhere in the + /// same task, must still let the next caller acquire the lock and receive + /// a credential rather than waiting on a lock nothing will ever release. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_dropped_acquisition_releases_the_refresh_lock_for_the_next_caller() { + let server = token_endpoint_serving( + issued(Some(TOKEN_LIFETIME_SECONDS)).set_delay(Duration::from_millis(200)), + ) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = Arc::new(provider(endpoint(&server.uri()), &clock)); + + let abandoned = { + let provider = provider.clone(); + tokio::spawn(async move { provider.bearer_token().await }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + abandoned.abort(); + let _ = abandoned.await; + + let token = tokio::time::timeout(Duration::from_secs(2), provider.bearer_token()) + .await + .expect("the refresh lock was not left held by the abandoned acquisition") + .expect("a subsequent caller still receives a credential"); + assert_eq!(token.expose(), ISSUED_CREDENTIAL); + } + /// A declined request reports the registered code and nothing else. The /// description is server-authored text about a failed authentication, so it /// must not reach the caller's diagnostic. @@ -937,9 +1026,7 @@ mod tests { for (status, body, expected) in cases { let server = token_endpoint_serving( - ResponseTemplate::new(status) - .insert_header("content-type", "application/json") - .set_body_string(body.clone()), + ResponseTemplate::new(status).set_body_raw(body.clone(), "application/json"), ) .await; let clock = Arc::new(TestClock::new(NOW)); @@ -955,6 +1042,40 @@ mod tests { } } + /// A 400 or 401 body is read as a refusal only when it is announced in the + /// media type the request asked for. An intermediary that answers in a + /// different media type, or none at all, never reached the authorization + /// server's own refusal logic, so it must be reported as a protocol failure + /// rather than as a refusal the adopter cannot act on. + #[tokio::test] + async fn a_declined_status_in_the_wrong_media_type_is_a_protocol_failure() { + let refusal = json!({"error": "invalid_request"}).to_string(); + let cases = [ + ( + 400, + ResponseTemplate::new(400).set_body_bytes(refusal.clone()), + "absent content type", + ), + ( + 401, + ResponseTemplate::new(401).set_body_raw(refusal.clone(), "text/plain"), + "wrong content type", + ), + ]; + + for (status, response, label) in cases { + let server = token_endpoint_serving(response).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("the answer is not a usable refusal"); + assert_eq!(error, TokenError::Protocol { status }, "{label}"); + } + } + /// An answer that is not a usable token response is a protocol failure, never /// a credential. Each of these would otherwise become a request the deployment /// refuses for a reason the adopter cannot see. From 338a1e2a746d76fea358d9531c9d3414415232da Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:20:46 +0700 Subject: [PATCH 20/67] docs(evidence): correct the client token provider documentation The module doc overclaimed that any server accepting this grant and authentication method would work; the request body carries only grant_type, client_assertion_type, and client_assertion, so a server that also requires a scope, a resource indicator, or a body client_id needs support this provider does not offer. The bearer_token() comment described the wait for an in-flight token request as bounded by the request timeout. It is bounded by the number of waiters ahead of a caller times that timeout, and the freshness check only spares a waiter its own request when the caller ahead of it actually cached something. with_assertion_lifetime_seconds and with_refresh_margin_seconds did not document their accepted range, though an out-of-range value only surfaces as a refusal later, when the provider is built. TokenProvider did not note that implementing it outside this crate requires the async-trait dependency directly. The README described the refresh margin backwards: a credential is replaced once it enters the margin, before it actually expires, not "before the refresh margin". No behavior changes. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/README.md | 3 ++- .../src/private_key_jwt.rs | 23 +++++++++++++------ crates/registry-evidence-client/src/token.rs | 3 +++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/registry-evidence-client/README.md b/crates/registry-evidence-client/README.md index 0d210d452..5cee52c8a 100644 --- a/crates/registry-evidence-client/README.md +++ b/crates/registry-evidence-client/README.md @@ -25,7 +25,8 @@ judgement about a response is made by `registry-evidence-verifier`. the OAuth 2.0 `client_credentials` grant and the `private_key_jwt` client authentication method of RFC 7523. It is plain OAuth against any authorization server offering that grant, it caches what it is issued, and it replaces a - credential before the refresh margin rather than after expiry. + credential once the credential enters the refresh margin, before it actually + expires. - `EvidenceClient::verify_as_of`: the same verification at an instant the caller names, for re-verifying a response it retained. diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index 124356478..16e6d5512 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -7,8 +7,10 @@ //! //! It is plain OAuth. Nothing here knows which authorization server it is talking //! to, and the provider carries no claim, route, or vocabulary belonging to any -//! particular issuer. Any server that accepts this grant and this authentication -//! method will do. +//! particular issuer. The request body carries only `grant_type`, +//! `client_assertion_type`, and `client_assertion`; a server that also requires a +//! scope, a resource indicator, or a body `client_id` on this grant needs support +//! this provider does not offer. //! //! # What is cached, and for how long //! @@ -153,6 +155,8 @@ impl PrivateKeyJwtConfig { self } + /// Must be within `1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS`; an out-of-range + /// value is refused when the provider is built rather than here. #[must_use] pub fn with_assertion_lifetime_seconds(mut self, seconds: i64) -> Self { self.assertion_lifetime_seconds = seconds; @@ -160,6 +164,9 @@ impl PrivateKeyJwtConfig { } /// Treat this much of an access token's remaining life as already spent. + /// + /// Must not be negative; a negative value is refused when the provider is + /// built rather than here. #[must_use] pub fn with_refresh_margin_seconds(mut self, seconds: i64) -> Self { self.refresh_margin_seconds = seconds; @@ -461,11 +468,13 @@ impl TokenProvider for PrivateKeyJwt { if let Some(token) = self.usable_cached_token(self.clock.unix_seconds()).await { return Ok(token); } - // One caller performs the token request while the others wait here, then - // find what it cached. A lock rather than a shared future keeps this - // readable, and the wait is bounded by the request timeout the integrator - // configured. The freshness check is repeated after the lock is taken, - // because the caller that held it has usually just cached a credential. + // The refresh lock serializes acquisition: one caller holds it and + // performs a token request while the rest wait their turn for it. The + // freshness check below spares a waiter its own request only when the + // caller ahead of it cached something; a credential that could not be + // cached, or a failed request, sends the next waiter to acquire its own + // in turn. A wait is therefore bounded by the number of callers ahead of + // it times the configured request timeout, not by that timeout alone. let _refreshing = self.refresh_lock.lock().await; let now = self.clock.unix_seconds(); if let Some(token) = self.usable_cached_token(now).await { diff --git a/crates/registry-evidence-client/src/token.rs b/crates/registry-evidence-client/src/token.rs index c5395c536..8155f400e 100644 --- a/crates/registry-evidence-client/src/token.rs +++ b/crates/registry-evidence-client/src/token.rs @@ -67,6 +67,9 @@ impl fmt::Debug for BearerToken { /// /// Implementations may cache, refresh, or mint a credential. The client calls /// this once per outbound request and never stores what it returns. +/// +/// This trait is `#[async_trait]`. An integrator implementing it outside this +/// crate needs the `async-trait` dependency themselves; it is not re-exported. #[async_trait] pub trait TokenProvider: Send + Sync { async fn bearer_token(&self) -> Result; From 1f876be5895ac871d54245bd80e610faab0c0374 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:20:54 +0700 Subject: [PATCH 21/67] docs: narrow the Mint dependency direction to runtime The AGENTS.md statement that Evidence does not depend on Mint no longer held once registry-evidence-client added registry-mint as a dev-dependency, to drive a real Mint instance in its own tests. Narrow the claim to production: no Evidence crate depends on Mint at runtime, and Evidence test code may depend on Mint to prove a client against a real authorization server. Signed-off-by: Jeremi Joslin --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e90b6bc0e..12deb3fc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,10 @@ adopter tooling; `registry-evidencectl` is Evidence adopter tooling. Registry Mint is a supporting service, not a third pattern: it issues the access tokens a resource server such as Evidence verifies, for deployments with -no identity provider. The dependency runs one way only. Mint's tests drive -Evidence's authenticator; Evidence does not depend on Mint. +no identity provider. The dependency runs one way only in production: no +Evidence crate depends on Mint at runtime. Mint's tests drive Evidence's +authenticator, and Evidence test code may drive a real Mint instance to prove a +client against a real authorization server. ## Repository map From c3cc27b144df4f0a2ebd9e8ed98a98550d366185 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:22:31 +0700 Subject: [PATCH 22/67] refactor(evidence): share the loopback harness in the client deployment tests Deployment and TokenIssuer each reserved an ephemeral loopback port the same way and each stopped their spawned service the same way in Drop. Factor both into reserve_loopback_port and stop_service, used by both harnesses in this integration test file. The closed_loopback_origin helper in src/client.rs is a separate compilation unit with its own #[cfg(test)] constraints and is left alone; this refactor is scoped to tests/against_a_real_deployment.rs. Signed-off-by: Jeremi Joslin --- .../tests/against_a_real_deployment.rs | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 9ea1c4270..051da95d9 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -742,6 +742,39 @@ fn selector_value(field: &SelectorField, subject_label: &str) -> SelectorValue { } } +// --------------------------------------------------------------------------- +// Shared loopback harness +// --------------------------------------------------------------------------- + +/// Reserve an ephemeral loopback port and read it back. +/// +/// The caller has to know the port before it authors the deployment that will +/// be served on it, so the listener is returned rather than dropped here. Hold +/// it until immediately before the real service binds the same port. +fn reserve_loopback_port() -> (TcpListener, u16) { + let reservation = TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); + let port = reservation + .local_addr() + .expect("read the reserved address") + .port(); + (reservation, port) +} + +/// Ask a spawned service to stop and abandon its task. +/// +/// A drop cannot await the graceful stop, so the task is aborted rather than +/// joined. Taking the shutdown sender is what makes this safe to call from a +/// `Drop` impl more than once. +fn stop_service( + shutdown: &mut Option>, + server: &tokio::task::JoinHandle>, +) { + if let Some(shutdown) = shutdown.take() { + let _ = shutdown.send(()); + } + server.abort(); +} + // --------------------------------------------------------------------------- // The deployment harness // --------------------------------------------------------------------------- @@ -836,13 +869,9 @@ impl Deployment { impl Drop for Deployment { fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - // A drop cannot await the graceful stop, so the task is abandoned - // rather than joined. The temporary deployment it reads is removed - // below, and the process ends with the test binary. - self.server.abort(); + // The temporary deployment the stopped task reads is removed below, + // and the process ends with the test binary. + stop_service(&mut self.shutdown, &self.server); // The runtime requires an immutable deployment, so the staged tree was // sealed. Restoring write permission is what lets the temporary // directory be removed; a failure here leaves a directory behind for @@ -896,11 +925,7 @@ async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> // Hold the allocation while the matching deployment is authored, and // release it only immediately before the service binds it. - let reservation = TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); - let port = reservation - .local_addr() - .expect("read the reserved address") - .port(); + let (reservation, port) = reserve_loopback_port(); let directory = tempfile::tempdir().expect("temporary deployment root"); let bundle_root = directory.path().join("bundle"); @@ -1086,13 +1111,9 @@ impl TokenIssuer { impl Drop for TokenIssuer { fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - // A drop cannot await the graceful stop, so the task is abandoned rather - // than joined. The temporary deployment it reads is removed with this + // The temporary deployment the stopped task reads is removed with this // struct, and the process ends with the test binary. - self.server.abort(); + stop_service(&mut self.shutdown, &self.server); } } @@ -1102,11 +1123,7 @@ async fn start_token_issuer() -> TokenIssuer { // Hold the allocation while the matching deployment is authored, and release // it only immediately before the service binds it. The issuer identity is // part of that deployment, so the port has to be known first. - let reservation = TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); - let port = reservation - .local_addr() - .expect("read the reserved address") - .port(); + let (reservation, port) = reserve_loopback_port(); let origin = format!("http://127.0.0.1:{port}"); let token_endpoint = Url::parse(&format!("{origin}/token")).expect("the token endpoint parses"); From 41ba5a03a7c3651bf4a67a5ced34d37b6346b72f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:51:36 +0700 Subject: [PATCH 23/67] test(evidence): pin the token provider's media type and cache boundaries Signed-off-by: Jeremi Joslin --- .../src/private_key_jwt.rs | 58 ++++++++++++++----- .../tests/against_a_real_deployment.rs | 2 +- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index 16e6d5512..c4dc3d8d3 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -913,18 +913,26 @@ mod tests { ); } - /// A server that states no lifetime has told the client nothing it may cache - /// against, so every request asks again rather than guessing a lifetime. + /// A server that states no lifetime, or one that is already zero or + /// negative, has told the client nothing it may cache against, so every + /// request acquires its own credential rather than writing an unusable one + /// into the cache. #[tokio::test] async fn a_credential_without_a_stated_lifetime_is_not_cached() { - let server = token_endpoint_serving(issued(None)).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); + for expires_in in [None, Some(0), Some(-1)] { + let server = token_endpoint_serving(issued(expires_in)).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); - provider.bearer_token().await.expect("a first credential"); - provider.bearer_token().await.expect("a second credential"); + provider.bearer_token().await.expect("a first credential"); + provider.bearer_token().await.expect("a second credential"); - assert_eq!(token_requests(&server).await, 2); + assert_eq!( + token_requests(&server).await, + 2, + "expires_in {expires_in:?} was cached" + ); + } } /// Many callers starting at once must not each open a token request. The @@ -986,6 +994,15 @@ mod tests { .expect("the refresh lock was not left held by the abandoned acquisition") .expect("a subsequent caller still receives a credential"); assert_eq!(token.expose(), ISSUED_CREDENTIAL); + // The abandoned task's own request reaching the server proves it was + // past the lock acquisition, and the response delay of four times the + // abort delay proves it was still awaiting that request, so still + // holding the lock, when the abort landed. + assert_eq!( + token_requests(&server).await, + 2, + "the abandoned task never reached the token request it would have held the lock for" + ); } /// A declined request reports the registered code and nothing else. The @@ -997,6 +1014,7 @@ mod tests { ( 400, json!({"error": "invalid_request"}).to_string(), + JSON_MEDIA_TYPE, TokenError::Refused { code: OAuthErrorCode::InvalidRequest, }, @@ -1005,6 +1023,7 @@ mod tests { 401, json!({"error": "invalid_client", "error_description": "canary assertion detail"}) .to_string(), + JSON_MEDIA_TYPE, TokenError::Refused { code: OAuthErrorCode::InvalidClient, }, @@ -1012,6 +1031,7 @@ mod tests { ( 400, json!({"error": "canary_extension_code"}).to_string(), + JSON_MEDIA_TYPE, TokenError::Refused { code: OAuthErrorCode::Other, }, @@ -1019,23 +1039,37 @@ mod tests { ( 400, "canary not json at all".to_owned(), + JSON_MEDIA_TYPE, TokenError::Protocol { status: 400 }, ), ( 403, json!({"error": "invalid_client"}).to_string(), + JSON_MEDIA_TYPE, TokenError::Protocol { status: 403 }, ), ( 500, json!({"error": "server_error"}).to_string(), + JSON_MEDIA_TYPE, TokenError::Protocol { status: 500 }, ), + // A real authorization server states a charset parameter on its + // JSON responses; the essence the gate compares against must + // still match with one present. + ( + 400, + json!({"error": "invalid_request"}).to_string(), + "application/json; charset=utf-8", + TokenError::Refused { + code: OAuthErrorCode::InvalidRequest, + }, + ), ]; - for (status, body, expected) in cases { + for (status, body, media_type, expected) in cases { let server = token_endpoint_serving( - ResponseTemplate::new(status).set_body_raw(body.clone(), "application/json"), + ResponseTemplate::new(status).set_body_raw(body.clone(), media_type), ) .await; let clock = Arc::new(TestClock::new(NOW)); @@ -1116,9 +1150,7 @@ mod tests { ), ( "an unreadable success body", - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_string("{"), + ResponseTemplate::new(200).set_body_raw("{", "application/json"), ), ( "a redirect instead of an answer", diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 051da95d9..88d86e9f1 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -869,7 +869,7 @@ impl Deployment { impl Drop for Deployment { fn drop(&mut self) { - // The temporary deployment the stopped task reads is removed below, + // The temporary deployment the abandoned task reads is removed below, // and the process ends with the test binary. stop_service(&mut self.shutdown, &self.server); // The runtime requires an immutable deployment, so the staged tree was From a72a8199db11b60df271880530ff95ce82e02d7e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 10:52:53 +0700 Subject: [PATCH 24/67] test(evidence): describe both abandoned service tasks alike The token issuer's teardown comment said the task was stopped, while the deployment's said it was abandoned. Both call the same helper, which requests cancellation without joining, so the second wording is the accurate one. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client/tests/against_a_real_deployment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 88d86e9f1..112057504 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -1111,7 +1111,7 @@ impl TokenIssuer { impl Drop for TokenIssuer { fn drop(&mut self) { - // The temporary deployment the stopped task reads is removed with this + // The temporary deployment the abandoned task reads is removed with this // struct, and the process ends with the test binary. stop_service(&mut self.shutdown, &self.server); } From da9af0d4f198a562d84803e41ce655e068c36598 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 13:49:23 +0700 Subject: [PATCH 25/67] feat(evidence): bind the Evidence client for Node Add registry-evidence-client-node, a thin napi-rs binding over registry-evidence-client, exposing prepare/discover/fetchJwks/send/verify/ requestAndVerify/verifyAsOf to JS callers. Every Evidence semantic decision stays in the wrapped Rust crate; this crate only converts values across the FFI boundary and maps client errors to a JSON-stringified napi::Error. Wire the new crate into the workspace and its surrounding checks: root Cargo.toml membership, the CI change-classifier's evidence shard and new client_bindings output, a client-bindings CI job that builds and tests the addon on the pinned Node version, source-neutrality checks, and the repository/product AGENTS.md maps. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 5 + .github/workflows/ci.yml | 30 + AGENTS.md | 1 + Cargo.lock | 107 +- Cargo.toml | 5 + .../registry-evidence-client-node/.gitignore | 3 + .../registry-evidence-client-node/Cargo.toml | 39 + crates/registry-evidence-client-node/LICENSE | 201 ++ .../registry-evidence-client-node/README.md | 119 + .../__test__/construction.test.js | 51 + .../__test__/discovery.test.js | 89 + .../__test__/errors.test.js | 185 ++ .../__test__/happy-path.test.js | 107 + .../__test__/helpers/live-signing.js | 124 + .../__test__/helpers/stub-server.js | 45 + crates/registry-evidence-client-node/build.rs | 3 + .../registry-evidence-client-node/index.d.ts | 136 ++ crates/registry-evidence-client-node/index.js | 705 ++++++ .../package-lock.json | 2005 +++++++++++++++++ .../package.json | 32 + .../src/convert.rs | 1092 +++++++++ .../registry-evidence-client-node/src/lib.rs | 326 +++ .../tests/fixtures/jwks.json | 11 + .../tests/fixtures/policy.json | 25 + .../tests/fixtures/response.jws.json | 5 + .../tests/golden_fixture.rs | 212 ++ products/evidence/AGENTS.md | 4 +- .../scripts/check-source-neutrality.sh | 2 + 28 files changed, 5667 insertions(+), 2 deletions(-) create mode 100644 crates/registry-evidence-client-node/.gitignore create mode 100644 crates/registry-evidence-client-node/Cargo.toml create mode 100644 crates/registry-evidence-client-node/LICENSE create mode 100644 crates/registry-evidence-client-node/README.md create mode 100644 crates/registry-evidence-client-node/__test__/construction.test.js create mode 100644 crates/registry-evidence-client-node/__test__/discovery.test.js create mode 100644 crates/registry-evidence-client-node/__test__/errors.test.js create mode 100644 crates/registry-evidence-client-node/__test__/happy-path.test.js create mode 100644 crates/registry-evidence-client-node/__test__/helpers/live-signing.js create mode 100644 crates/registry-evidence-client-node/__test__/helpers/stub-server.js create mode 100644 crates/registry-evidence-client-node/build.rs create mode 100644 crates/registry-evidence-client-node/index.d.ts create mode 100644 crates/registry-evidence-client-node/index.js create mode 100644 crates/registry-evidence-client-node/package-lock.json create mode 100644 crates/registry-evidence-client-node/package.json create mode 100644 crates/registry-evidence-client-node/src/convert.rs create mode 100644 crates/registry-evidence-client-node/src/lib.rs create mode 100644 crates/registry-evidence-client-node/tests/fixtures/jwks.json create mode 100644 crates/registry-evidence-client-node/tests/fixtures/policy.json create mode 100644 crates/registry-evidence-client-node/tests/fixtures/response.jws.json create mode 100644 crates/registry-evidence-client-node/tests/golden_fixture.rs diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index fe7d98fa1..cbaeed219 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -34,6 +34,7 @@ "evidence": ( "registry-evidence", "registry-evidence-client", + "registry-evidence-client-node", "registry-evidence-verifier", "registry-evidencectl", ), @@ -468,6 +469,9 @@ def classify( for path in paths ) editors = complete or any(path.startswith("editors/") for path in paths) + client_bindings = complete or any( + path.startswith("crates/registry-evidence-client-node/") for path in paths + ) tutorial_infrastructure = any( path @@ -550,6 +554,7 @@ def classify( "docs": docs, "docs_archives": docs_archives, "editors": editors, + "client_bindings": client_bindings, "registryctl_tutorial": registryctl_tutorial, "evidence_tutorial": evidence_tutorial, } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24cb7441b..3c7b0e273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: docs: ${{ steps.filter.outputs.docs }} docs_archives: ${{ steps.filter.outputs.docs_archives }} editors: ${{ steps.filter.outputs.editors }} + client_bindings: ${{ steps.filter.outputs.client_bindings }} registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }} evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} steps: @@ -1158,6 +1159,34 @@ jobs: cargo check --locked --target wasm32-wasip2 --manifest-path editors/zed/Cargo.toml cmp LICENSE editors/zed/LICENSE + client-bindings: + name: Evidence client bindings + needs: changes + if: needs.changes.outputs.client_bindings == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + submodules: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: crates/registry-evidence-client-node/package-lock.json + + - name: Build and test the Node binding + working-directory: crates/registry-evidence-client-node + run: | + npm ci + npm run build:debug + npm test + npm run check:types + cmp ../../LICENSE LICENSE + ci-result: name: CI result if: always() @@ -1176,6 +1205,7 @@ jobs: - evidence-tutorials - docs - editor-extensions + - client-bindings runs-on: ubuntu-24.04 env: CI_JOB_RESULTS: ${{ toJSON(needs) }} diff --git a/AGENTS.md b/AGENTS.md index 12deb3fc0..9c6f93f6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ client against a real authorization server. | `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | | `crates/registry-evidence-verifier` | Portable Evidence response verification, shared by the runtime and client tooling | | `crates/registry-evidence-client` | Evidence relying-party SDK: requests assertions and verifies them via `registry-evidence-verifier` | +| `crates/registry-evidence-client-node` | Node.js binding for `registry-evidence-client`, via napi-rs | | `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, incomplete OpenAPI authoring workspaces, fixture runs for complete projects | | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | diff --git a/Cargo.lock b/Cargo.lock index 815444681..53f40e2f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1164,6 +1164,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1494,6 +1503,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" + [[package]] name = "ctutils" version = "0.4.2" @@ -2351,7 +2366,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -4025,6 +4040,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "liblzma" version = "0.4.6" @@ -4243,6 +4268,66 @@ dependencies = [ "byteorder", ] +[[package]] +name = "napi" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f71d6bc097c4a6eb853c3f24991ab8c9f50f57d1f719e305175541482217e36" +dependencies = [ + "bitflags 2.13.0", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5282704fbe8d49b0cf8b08e3f33233416a528658f205c7e5ace63b582de0b11c" + +[[package]] +name = "napi-derive" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9002b2940f0184444754546e0fcd15182f56948e6f381968b019d549387c42" +dependencies = [ + "convert_case 0.11.0", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" +dependencies = [ + "libloading", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -5482,6 +5567,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "registry-evidence-client-node" +version = "0.17.0" +dependencies = [ + "base64", + "chrono", + "ed25519-dalek", + "getrandom 0.4.3", + "napi", + "napi-build", + "napi-derive", + "registry-evidence-client", + "registry-evidence-verifier", + "registry-platform-crypto", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "registry-evidence-verifier" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index d6f2ac0d1..2eab7860e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/registry-config-report", "crates/registry-evidence", "crates/registry-evidence-client", + "crates/registry-evidence-client-node", "crates/registry-evidence-verifier", "crates/registry-evidencectl", "crates/registry-platform-audit", @@ -45,6 +46,7 @@ unsafe_code = "forbid" registry-config-report = { path = "crates/registry-config-report", version = "0.17.0" } registry-evidence = { path = "crates/registry-evidence", version = "0.17.0" } registry-evidence-client = { path = "crates/registry-evidence-client", version = "0.17.0" } +registry-evidence-client-node = { path = "crates/registry-evidence-client-node", version = "0.17.0" } registry-evidence-verifier = { path = "crates/registry-evidence-verifier", version = "0.17.0" } registry-language-server = { path = "crates/registry-language-server", version = "0.17.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.17.0" } @@ -107,6 +109,9 @@ ipnet = { version = "2" } # already holds in memory, so neither belongs in the dependency graph. jsonschema = { version = "0.18", default-features = false, features = ["draft202012"] } jsonwebtoken = { version = "10", default-features = false, features = ["aws_lc_rs"] } +napi = { version = "3.12.0", features = ["async", "serde-json"] } +napi-build = { version = "2.4.0" } +napi-derive = { version = "3.6.2" } native-tls = { version = "0.2" } ogcapi-types = { version = "0.3.0", features = ["features"] } p256 = { version = "0.13", features = ["ecdsa"] } diff --git a/crates/registry-evidence-client-node/.gitignore b/crates/registry-evidence-client-node/.gitignore new file mode 100644 index 000000000..79946157d --- /dev/null +++ b/crates/registry-evidence-client-node/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.node +index.d.ts.check diff --git a/crates/registry-evidence-client-node/Cargo.toml b/crates/registry-evidence-client-node/Cargo.toml new file mode 100644 index 000000000..3eb62362b --- /dev/null +++ b/crates/registry-evidence-client-node/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "registry-evidence-client-node" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Node.js binding for the Evidence relying-party client, via napi-rs." +readme = "README.md" +repository.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +# napi-rs's generated glue (through the `napi`/`napi-derive` dependency, not +# this crate's own source) registers FFI entry points with unsafe code +# unconditionally, which the workspace's `unsafe_code = "forbid"` lint would +# reject. This crate opts out of `[lints] workspace = true` and instead denies +# unsafe code in its own source from `src/lib.rs`, which is the strongest +# constraint napi-rs usage can satisfy here. + +[dependencies] +chrono.workspace = true +napi.workspace = true +napi-derive.workspace = true +registry-evidence-client.workspace = true +registry-platform-crypto.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true + +[build-dependencies] +napi-build.workspace = true + +[dev-dependencies] +base64.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +registry-evidence-verifier.workspace = true +serde.workspace = true diff --git a/crates/registry-evidence-client-node/LICENSE b/crates/registry-evidence-client-node/LICENSE new file mode 100644 index 000000000..0421f3c2d --- /dev/null +++ b/crates/registry-evidence-client-node/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Jeremi Joslin + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/registry-evidence-client-node/README.md b/crates/registry-evidence-client-node/README.md new file mode 100644 index 000000000..044f0767b --- /dev/null +++ b/crates/registry-evidence-client-node/README.md @@ -0,0 +1,119 @@ +# registry-evidence-client-node + +Node.js binding for `registry-evidence-client`, the Evidence relying-party +client, via [napi-rs](https://napi.rs). Every Evidence semantic decision +(request preparation, sending, and verification) is the wrapped Rust crate's +own; this crate is a thin `#[napi]` surface plus a JSON conversion layer, and +re-implements none of it. + +Publishing this binding as `@registrystack/evidence-client` on npm is out of +scope for this crate; it currently exists to be built and tested locally and +in CI. + +## JS surface + +```js +const client = new EvidenceClient({ baseUrl, trustedJwks, token, ... }); + +const prepared = client.prepare(spec); // synchronous, no I/O +const definitions = await client.discover(); +const jwks = await client.fetchJwks(); +const response = await client.send(prepared); +const verified = client.verify(prepared, response); // synchronous +const verified = await client.requestAndVerify(prepared); +const verified = client.verifyAsOf(prepared, response, asOfMillis); +``` + +## Design notes + +### Error mapping + +Every failure that crosses from Rust to JS is a thrown `napi::Error` whose +`message` is a JSON-stringified envelope (`kind`, `message`, plus fields +specific to that kind). Callers must `JSON.parse(error.message)` rather than +matching on the error's own type. `kind` is one of: `configuration`, `nonce`, +`token`, `transport`, `denied`, `not_available`, `protocol`, `verification`. + +The `denied`/`protocol` split is a hazard worth calling out explicitly: HTTP +401, 403, and 429 all map to `denied` regardless of the response body's own +`code`, while every other non-2xx status (including 400, 500, and anything +else not specifically recognized) maps to `protocol`. A caller that only +checks `status` without also checking `kind` can misclassify a 429 rate limit +as a generic protocol failure, or vice versa. See +`registry-evidence-client`'s `problem.rs` for the authoritative mapping table. + +A response that exceeds `maxResponseBytes` maps to `kind: "transport"` with +`transportKind: "response_too_large"`, not `kind: "protocol"`, even when the +response status itself was a plain 200: the size limit is enforced against the +transport, before any attempt to interpret the body as a problem response. + +### Nonce and the golden fixture + +`prepare()` generates a fresh request nonce on every call; there is no seam +to inject a fixed nonce from outside. `tests/golden_fixture.rs` and its +committed fixtures under `tests/fixtures/` exist because of this: they pin one +specific, already-issued signed response (with its own fixed nonce baked in) +so the conversion layer's verification path can be exercised deterministically +without needing a live signer. Regenerate the fixture only with: + +```bash +cargo test -p registry-evidence-client-node --test golden_fixture -- --ignored regenerate_golden_fixture +``` + +never by hand-editing the fixture files. The JS tests under `__test__/` take +the opposite approach for their own live round trip: `helpers/live-signing.js` +signs a fresh Evidence payload with Node's built-in `crypto` (Ed25519) for +whatever nonce the prepared request actually generated, because neither +`registry-evidence-verifier` nor `registry-evidence-client` exposes its test +signer outside `cfg(test)`. + +### Async bridging + +`send` and `requestAndVerify` cannot be plain `async fn` methods taking a +class reference as a parameter: napi-rs's tokio bridge requires the generated +future to be `Send + 'static`, and a `Reference` into a JS object is not +`Send`. Both methods are ordinary (non-async) `#[napi]` functions that clone +the `Arc`s they need synchronously, then hand an `async move` block built only +from those owned clones to `napi::Env::spawn_future`. `PreparedEvidenceRequest` +and `RawEvidenceResponse` cross as opaque classes wrapping an `Arc` around the +real Rust value for the same reason: cloning the `Arc` is cheap and, for a +prepared request, preserves the identity of the interior single-send guard +rather than resetting it. + +### `unsafe_code` deviation + +This crate opts out of the workspace's `[lints] workspace = true` (which sets +`unsafe_code = "forbid"`) and instead denies unsafe code in its own source +from `src/lib.rs` (`#![deny(unsafe_code)]`). napi-rs's generated FFI glue +(through the `napi`/`napi-derive` dependency, not this crate's own source) +registers entry points with unsafe code unconditionally, which the +workspace-wide forbid would reject outright. + +### Configuration surface + +- `trustedRootCertificates` accepts a PEM-encoded string only, not a Buffer or + DER bytes. +- Exactly two token providers are supported: `token: { static: "..." }` and + `token: { privateKeyJwt: { tokenEndpoint, clientId, clientKey, ... } }`. A + caller-supplied custom token provider is out of scope for this binding. +- `verifyAsOf(prepared, response, asOfMillis)` judges a response as of an + explicit instant rather than the live clock. A past instant is the direction + that costs something: naming a stale instant accepts an assertion whose + validity interval has since elapsed, because the question asked is whether + it was acceptable *then*, and the answer stays yes forever. A live trust + decision should call `verify`, not this. + +## Testing + +Rust unit tests (`cargo test -p registry-evidence-client-node`) cover the +conversion layer directly. JS tests (`npm test`, `node --test __test__/*.test.js`) +cover construction refusals, a live happy-path round trip against a local stub +server, the one-send guard, `discover`/`fetchJwks` against a stub, and error +mapping for denied/not-available/protocol/transport failures. Building the +native addon first (`npm run build:debug`) is required before running the JS +tests. + +`npm run check:types` rebuilds the addon in release mode with `--dts` and +diffs the result against the committed `index.d.ts`, so a change to the +`#[napi]` surface that is not reflected in the committed declaration file +fails this check rather than silently drifting. diff --git a/crates/registry-evidence-client-node/__test__/construction.test.js b/crates/registry-evidence-client-node/__test__/construction.test.js new file mode 100644 index 000000000..6edbf6981 --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/construction.test.js @@ -0,0 +1,51 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { EvidenceClient } = require('../index.js'); + +function validConfig(overrides = {}) { + return { + baseUrl: 'https://evidence.example.org', + trustedJwks: { + keys: [ + { + kty: 'OKP', + crv: 'Ed25519', + kid: 'construction-test-key', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + ], + }, + token: { static: 'construction-test-token' }, + ...overrides, + }; +} + +/** Every construction refusal in this file is the Rust configuration + * failure, crossing as a thrown error whose `message` is the stable JSON + * envelope `kind: "configuration"`. */ +function assertConfigurationRefusal(build) { + assert.throws(build, (error) => { + const mapped = JSON.parse(error.message); + assert.equal(mapped.kind, 'configuration'); + return true; + }); +} + +test('a non-HTTPS, non-loopback base URL is refused', () => { + assertConfigurationRefusal( + () => new EvidenceClient(validConfig({ baseUrl: 'http://evidence.example.org' })), + ); +}); + +test('an empty trusted key set is refused', () => { + assertConfigurationRefusal(() => new EvidenceClient(validConfig({ trustedJwks: { keys: [] } }))); +}); + +test('a base URL with an empty path segment is refused', () => { + assertConfigurationRefusal( + () => new EvidenceClient(validConfig({ baseUrl: 'https://evidence.example.org/prefix//suffix' })), + ); +}); diff --git a/crates/registry-evidence-client-node/__test__/discovery.test.js b/crates/registry-evidence-client-node/__test__/discovery.test.js new file mode 100644 index 000000000..48d199d2f --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/discovery.test.js @@ -0,0 +1,89 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { EvidenceClient } = require('../index.js'); +const { startStubServer } = require('./helpers/stub-server'); + +// The golden fixture's key set is public verification material only (no +// private key), so it is safe to reuse here for `fetchJwks`, which never +// needs to sign anything. +const GOLDEN_JWKS = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'tests', 'fixtures', 'jwks.json'), 'utf8'), +); + +const DEFINITIONS_DOCUMENT = { + schema: 'registry.evidence-definitions/v1', + assuranceProfile: 'local', + configurationRevision: `sha256:${'0'.repeat(64)}`, + issuedBy: 'urn:example:node-test:issuer', + providedBy: 'urn:example:node-test:provider', + definitions: [ + { + requirement: 'urn:example:node-test:requirement:status:v1', + kind: 'criterion', + evidenceType: 'urn:example:node-test:evidence-type:status:v1', + purpose: 'example-decision', + referenceFrameworks: ['urn:example:node-test:framework:status:v1'], + subjects: [ + { + role: 'subject', + cardinality: 'one', + selector: { + profile: 'record-lookup-v1', + valueOrigin: 'request', + fields: [{ type: 'string', name: 'record_reference', minimumBytes: 1, maximumBytes: 200 }], + }, + }, + ], + concepts: [{ id: 'urn:example:node-test:concept:status-holds', form: 'boolean' }], + }, + ], +}; + +function clientAgainst(stub) { + return new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: GOLDEN_JWKS, + token: { static: 'discovery-test-token' }, + }); +} + +test('discover reads a valid definitions document from a stub deployment', async () => { + const stub = await startStubServer({ + 'GET /v1/evidence-definitions': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(DEFINITIONS_DOCUMENT)); + }, + }); + try { + const client = clientAgainst(stub); + const document = await client.discover(); + assert.equal(document.schema, 'registry.evidence-definitions/v1'); + assert.equal(document.definitions.length, 1); + assert.equal(document.definitions[0].requirement, 'urn:example:node-test:requirement:status:v1'); + assert.equal(stub.requests.length, 1); + } finally { + await stub.close(); + } +}); + +test('fetchJwks reads the deployment key set from a stub deployment', async () => { + const stub = await startStubServer({ + 'GET /.well-known/evidence/jwks.json': (req, res) => { + res.writeHead(200, { 'content-type': 'application/jwk-set+json' }); + res.end(JSON.stringify(GOLDEN_JWKS)); + }, + }); + try { + const client = clientAgainst(stub); + const jwks = await client.fetchJwks(); + assert.deepEqual(jwks, GOLDEN_JWKS); + assert.equal(stub.requests.length, 1); + } finally { + await stub.close(); + } +}); diff --git a/crates/registry-evidence-client-node/__test__/errors.test.js b/crates/registry-evidence-client-node/__test__/errors.test.js new file mode 100644 index 000000000..e4cb90f57 --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/errors.test.js @@ -0,0 +1,185 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { EvidenceClient } = require('../index.js'); +const { startStubServer } = require('./helpers/stub-server'); +const { requestSpec } = require('./helpers/live-signing'); + +const DUMMY_JWKS = { + keys: [ + { + kty: 'OKP', + crv: 'Ed25519', + kid: 'errors-test-key', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + ], +}; + +/** None of these cases reach signature verification: the client maps them to + * a failure before the response body is ever trusted, so the stub never + * needs to sign anything real. */ +async function clientAndPrepared(stub) { + const client = new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: DUMMY_JWKS, + token: { static: 'errors-test-token' }, + }); + return { client, prepared: client.prepare(requestSpec()) }; +} + +function problemBody(status, code, operation) { + return JSON.stringify({ + type: 'https://registrystack.org/problems/example', + title: 'Example problem', + status, + code, + operation, + }); +} + +async function assertMappedFailure(stub, assertMapping) { + try { + const { client, prepared } = await clientAndPrepared(stub); + await assert.rejects(client.send(prepared), (error) => { + assertMapping(JSON.parse(error.message)); + return true; + }); + } finally { + await stub.close(); + } +} + +test('401 with any code maps to a denied failure', async () => { + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(401, { 'content-type': 'application/problem+json', 'x-request-id': 'op401test' }); + res.end(problemBody(401, 'authentication_failed', 'op401test')); + }, + }); + await assertMappedFailure(stub, (mapped) => { + assert.equal(mapped.kind, 'denied'); + assert.equal(mapped.status, 401); + assert.equal(mapped.code, 'authentication_failed'); + assert.equal(mapped.operation, 'op401test'); + assert.equal(mapped.retryAfterSeconds, undefined); + }); +}); + +test('403 with any code maps to a denied failure', async () => { + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(403, { 'content-type': 'application/problem+json', 'x-request-id': 'op403test' }); + res.end(problemBody(403, 'not_authorized', 'op403test')); + }, + }); + await assertMappedFailure(stub, (mapped) => { + assert.equal(mapped.kind, 'denied'); + assert.equal(mapped.status, 403); + assert.equal(mapped.code, 'not_authorized'); + assert.equal(mapped.operation, 'op403test'); + assert.equal(mapped.retryAfterSeconds, undefined); + }); +}); + +test('429 with a Retry-After header maps to a denied failure carrying the wait', async () => { + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(429, { + 'content-type': 'application/problem+json', + 'x-request-id': 'op429test', + 'retry-after': '30', + }); + res.end(problemBody(429, 'rate_limited', 'op429test')); + }, + }); + await assertMappedFailure(stub, (mapped) => { + assert.equal(mapped.kind, 'denied'); + assert.equal(mapped.status, 429); + assert.equal(mapped.code, 'rate_limited'); + assert.equal(mapped.operation, 'op429test'); + assert.equal(mapped.retryAfterSeconds, 30); + }); +}); + +test('422 with the not-available code maps to its own failure, with no status field', async () => { + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(422, { + 'content-type': 'application/problem+json', + 'x-request-id': 'op422test', + }); + res.end(problemBody(422, 'evidence_not_available', 'op422test')); + }, + }); + await assertMappedFailure(stub, (mapped) => { + assert.equal(mapped.kind, 'not_available'); + assert.equal(mapped.status, undefined); + assert.equal(mapped.operation, 'op422test'); + }); +}); + +test('400 with an ordinary contract code maps to a protocol failure', async () => { + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(400, { + 'content-type': 'application/problem+json', + 'x-request-id': 'op400test', + }); + res.end(problemBody(400, 'malformed_request', 'op400test')); + }, + }); + await assertMappedFailure(stub, (mapped) => { + assert.equal(mapped.kind, 'protocol'); + assert.equal(mapped.status, 400); + assert.equal(mapped.code, 'malformed_request'); + assert.equal(mapped.operation, 'op400test'); + assert.equal(mapped.retryAfterSeconds, undefined); + }); +}); + +test('a 200 response under the wrong media type is refused as a protocol failure', async () => { + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{}'); + }, + }); + await assertMappedFailure(stub, (mapped) => { + assert.equal(mapped.kind, 'protocol'); + assert.equal(mapped.status, 200); + assert.equal(mapped.code, undefined); + }); +}); + +test('a response over maxResponseBytes is refused as a transport failure, not a protocol failure', async () => { + const oversized = Buffer.alloc(4096, 'a'); + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res) => { + res.writeHead(200, { + 'content-type': 'application/jose+json', + 'content-length': String(oversized.length), + }); + res.end(oversized); + }, + }); + try { + const client = new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: DUMMY_JWKS, + token: { static: 'errors-test-token' }, + maxResponseBytes: 16, + }); + const prepared = client.prepare(requestSpec()); + await assert.rejects(client.send(prepared), (error) => { + const mapped = JSON.parse(error.message); + assert.equal(mapped.kind, 'transport'); + assert.equal(mapped.transportKind, 'response_too_large'); + return true; + }); + } finally { + await stub.close(); + } +}); diff --git a/crates/registry-evidence-client-node/__test__/happy-path.test.js b/crates/registry-evidence-client-node/__test__/happy-path.test.js new file mode 100644 index 000000000..9d3618485 --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/happy-path.test.js @@ -0,0 +1,107 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { EvidenceClient } = require('../index.js'); +const { startStubServer } = require('./helpers/stub-server'); +const { + generateSigningKey, + signEvidence, + requestSpec, + evidenceFor, + SUBJECT_BINDING, +} = require('./helpers/live-signing'); + +/** A stub that signs a fresh, currently valid Evidence answer for whatever + * nonce the live request actually carried. The golden fixture's fixed nonce + * cannot serve this: `prepare()` generates a new one on every call, and there + * is no seam to inject the fixture's nonce into it. */ +function evidenceRoute(spec, signingKey) { + return (req, res, body) => { + const requestBody = JSON.parse(body.toString('utf8')); + const evidence = evidenceFor(spec, requestBody.requestNonce); + const jws = signEvidence(evidence, signingKey); + res.writeHead(200, { 'content-type': 'application/jose+json' }); + res.end(JSON.stringify(jws)); + }; +} + +test('a prepared request round-trips through send and verify against a live stub', async () => { + const signingKey = generateSigningKey('happy-path-key'); + const spec = requestSpec(); + const stub = await startStubServer({ 'POST /v1/evidence': evidenceRoute(spec, signingKey) }); + + try { + const client = new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: signingKey.jwks, + token: { static: 'happy-path-token' }, + }); + + const prepared = client.prepare(spec); + const response = await client.send(prepared); + const verified = client.verify(prepared, response); + + assert.equal(verified.evidence.requestNonce, prepared.requestNonce); + assert.deepEqual(verified.pinnedSubjectExpectations, [ + { role: 'subject', binding: SUBJECT_BINDING }, + ]); + assert.equal(stub.requests.length, 1); + assert.equal(stub.requests[0].headers.authorization, 'Bearer happy-path-token'); + } finally { + await stub.close(); + } +}); + +test('requestAndVerify performs the same round trip in one call', async () => { + const signingKey = generateSigningKey('happy-path-key-2'); + const spec = requestSpec(); + const stub = await startStubServer({ 'POST /v1/evidence': evidenceRoute(spec, signingKey) }); + + try { + const client = new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: signingKey.jwks, + token: { static: 'happy-path-token' }, + }); + + const prepared = client.prepare(spec); + const verified = await client.requestAndVerify(prepared); + + assert.equal(verified.evidence.requestNonce, prepared.requestNonce); + assert.deepEqual(verified.pinnedSubjectExpectations, [ + { role: 'subject', binding: SUBJECT_BINDING }, + ]); + assert.equal(stub.requests.length, 1); + } finally { + await stub.close(); + } +}); + +test('a second send on the same prepared request is refused locally, and the stub sees only one request', async () => { + const signingKey = generateSigningKey('one-send-guard-key'); + const spec = requestSpec(); + const stub = await startStubServer({ 'POST /v1/evidence': evidenceRoute(spec, signingKey) }); + + try { + const client = new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: signingKey.jwks, + token: { static: 'one-send-guard-token' }, + }); + + const prepared = client.prepare(spec); + await client.send(prepared); + assert.equal(stub.requests.length, 1); + + await assert.rejects(client.send(prepared), (error) => { + const mapped = JSON.parse(error.message); + assert.equal(mapped.kind, 'configuration'); + return true; + }); + assert.equal(stub.requests.length, 1, 'the stub must not see a second request'); + } finally { + await stub.close(); + } +}); diff --git a/crates/registry-evidence-client-node/__test__/helpers/live-signing.js b/crates/registry-evidence-client-node/__test__/helpers/live-signing.js new file mode 100644 index 000000000..2a0793692 --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/helpers/live-signing.js @@ -0,0 +1,124 @@ +'use strict'; + +const crypto = require('node:crypto'); + +// Mirrors `registry-evidence-verifier`'s own wire constants +// (`EVIDENCE_JWS_TYP`, `EVIDENCE_JWS_CTY`, `EVIDENCE_SCHEMA_V1`). Neither that +// crate nor `registry-evidence-client` exposes them outside `cfg(test)`, so +// this file states them again rather than reaching into a private module. +const EVIDENCE_JWS_TYP = 'evidence+jws'; +const EVIDENCE_JWS_CTY = 'application/evidence+json'; +const EVIDENCE_SCHEMA_V1 = 'registry.assertion-evidence/v1'; +const EVIDENCE_JWS_MEDIA_TYPE = 'application/jose+json'; + +/** + * A fresh Ed25519 signing key for one stub deployment. + * + * Neither crate that can sign a real Evidence response + * (`registry-evidence-verifier`, `registry-evidence-client`) exposes its test + * signer outside `cfg(test)`, so a JS test that needs a live, nonce-matched + * response signs its own with Node's built-in `crypto`, the same way + * `tests/golden_fixture.rs` signs its committed fixture with + * `registry-platform-crypto` on the Rust side. This key is generated fresh + * per test and never written anywhere. + */ +function generateSigningKey(kid) { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + const jwk = publicKey.export({ format: 'jwk' }); + return { + kid, + privateKey, + jwks: { keys: [{ ...jwk, kid, alg: 'EdDSA' }] }, + }; +} + +/** Sign an Evidence payload as a flattened JWS, matching the wire format `verify_flattened_jws` expects. */ +function signEvidence(evidence, signingKey) { + const protectedHeader = { + alg: 'EdDSA', + kid: signingKey.kid, + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }; + const protectedSegment = Buffer.from(JSON.stringify(protectedHeader)).toString('base64url'); + const payloadSegment = Buffer.from(JSON.stringify(evidence)).toString('base64url'); + const signingInput = `${protectedSegment}.${payloadSegment}`; + const signature = crypto.sign(null, Buffer.from(signingInput), signingKey.privateKey); + return { + protected: protectedSegment, + payload: payloadSegment, + signature: signature.toString('base64url'), + }; +} + +/** + * A request specification whose every policy expectation (but the nonce, and + * the subject binding, which `subjectExpectations: "acceptFirstUse"` leaves + * to the response) is fixed and known ahead of the request, so a stub + * handler can build a matching, currently valid `Evidence` answer once it + * reads the nonce off the prepared request. + */ +function requestSpec() { + return { + requirement: 'urn:example:node-test:requirement:status:v1', + purpose: 'example-decision', + audience: 'urn:example:node-test:audience', + evidenceType: 'urn:example:node-test:evidence-type:status:v1', + issuedBy: 'urn:example:node-test:issuer', + providedBy: 'urn:example:node-test:provider', + configurationRevision: `sha256:${'0'.repeat(64)}`, + expectedAssuranceProfile: 'local', + subjects: [ + { + role: 'subject', + selectorProfile: 'record-lookup-v1', + selectorValues: { record_reference: 'R-001' }, + }, + ], + expectedOutputs: [{ concept: 'urn:example:node-test:concept:status-holds', form: 'boolean' }], + maximumAssertionLifetimeSeconds: 300, + clockSkewSeconds: 60, + subjectExpectations: 'acceptFirstUse', + }; +} + +/** The subject binding `evidenceFor` issues, for a test to assert first-use + * acceptance pinned exactly this. The Evidence payload contract requires a + * subject binding to match `urn:evidence:subject:v_<43 base64url + * characters>`, the same shape `prepare()` uses for a request nonce, so this + * is a fixed, schema-valid value rather than an arbitrary label. */ +const SUBJECT_BINDING = `urn:evidence:subject:v1_${crypto.randomBytes(32).toString('base64url')}`; + +/** An `Evidence` payload matching every expectation `requestSpec()` closes, for the given live request nonce. */ +function evidenceFor(spec, nonce) { + const issuedAt = new Date().toISOString().replace(/\.\d+Z$/, 'Z'); + const validUntil = new Date(Date.now() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z'); + return { + schema: EVIDENCE_SCHEMA_V1, + assuranceProfile: spec.expectedAssuranceProfile, + requestNonce: nonce, + id: 'urn:example:node-test:evidence:1', + type: 'Evidence', + supportsRequirement: spec.requirement, + isConformantTo: spec.evidenceType, + issuedBy: spec.issuedBy, + providedBy: spec.providedBy, + issuedAt, + observedAt: issuedAt, + validUntil, + purpose: spec.purpose, + audience: spec.audience, + configurationRevision: spec.configurationRevision, + subjects: [{ role: 'subject', binding: SUBJECT_BINDING }], + supportedValues: [{ providesValueFor: 'urn:example:node-test:concept:status-holds', value: true }], + }; +} + +module.exports = { + generateSigningKey, + signEvidence, + requestSpec, + evidenceFor, + SUBJECT_BINDING, + EVIDENCE_JWS_MEDIA_TYPE, +}; diff --git a/crates/registry-evidence-client-node/__test__/helpers/stub-server.js b/crates/registry-evidence-client-node/__test__/helpers/stub-server.js new file mode 100644 index 000000000..98f066385 --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/helpers/stub-server.js @@ -0,0 +1,45 @@ +'use strict'; + +const http = require('node:http'); + +/** + * Start a loopback-only HTTP server for one test's stub Evidence deployment. + * + * `routes` maps `"METHOD /path"` to a handler `(req, res, body) => void`; a + * request that matches no route gets a plain 404. Every request is recorded + * in the returned `requests` array before its handler runs, so a test can + * assert exactly how many requests reached the stub, not just what the + * client returned. + */ +function startStubServer(routes) { + const requests = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks); + requests.push({ method: req.method, url: req.url, headers: req.headers, body }); + const handler = routes[`${req.method} ${req.url}`]; + if (!handler) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('no stub route for this request'); + return; + } + handler(req, res, body); + }); + }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ + baseUrl: `http://127.0.0.1:${port}/`, + requests, + close: () => new Promise((res) => server.close(() => res())), + }); + }); + }); +} + +module.exports = { startStubServer }; diff --git a/crates/registry-evidence-client-node/build.rs b/crates/registry-evidence-client-node/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/crates/registry-evidence-client-node/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/crates/registry-evidence-client-node/index.d.ts b/crates/registry-evidence-client-node/index.d.ts new file mode 100644 index 000000000..6975d9cd6 --- /dev/null +++ b/crates/registry-evidence-client-node/index.d.ts @@ -0,0 +1,136 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +/** A relying party's connection to one Evidence deployment. */ +export declare class EvidenceClient { + /** + * Build a client for one deployment. `trustedJwks` is mandatory; an empty + * key set is refused, exactly as the Rust configuration is. + */ + constructor(config: any) + /** + * Close the expectations for one request and generate its nonce. No I/O + * happens here. The returned request is good for exactly one exchange: + * spend it with `send` or `requestAndVerify`. + */ + prepare(spec: any): PreparedEvidenceRequest + /** + * Read the request shapes this requester is entitled to send. Discovery + * is authoring input, not a trust anchor: it never supplies verification + * expectations for a request already in flight. + */ + discover(): Promise + /** + * Read the deployment's published verification key set, for an + * out-of-band pinning workflow. Verification never calls this: a key set + * fetched from the same origin as the response it would verify + * establishes nothing. + */ + fetchJwks(): Promise + /** + * Send one prepared request and read the signed response. + * + * `prepared` allows exactly one send: a second call with the same object + * rejects with the configuration failure the Rust layer already + * produces, without reaching the deployment. Retrying means preparing + * again, for a fresh nonce. + */ + send(prepared: PreparedEvidenceRequest): Promise + /** + * Verify a signed response against the policy its request closed, as of + * now. The trusted key set is the one pinned at construction, always. + * + * Unlike sending, verifying is unrestricted: it is offline and + * idempotent, so a retained response may be re-verified against a + * retained prepared request as often as needed, including after the + * single send has been spent. + */ + verify(prepared: PreparedEvidenceRequest, response: RawEvidenceResponse): VerifiedEvidence + /** + * Request evidence and verify it in one step. This spends the single + * send `prepared` allows, exactly as `send` does, so calling it twice + * with one prepared request fails locally on the second call. + */ + requestAndVerify(prepared: PreparedEvidenceRequest): Promise + /** + * Verify a retained response as of an explicit instant, given as + * milliseconds since the Unix epoch. + * + * `verify` judges a response against the current clock, which is right + * when the response has just arrived. This variant names the instant + * instead, for re-verifying a retained response or replaying a retained + * transaction record at the instant the original decision was made. + * + * A past instant is the direction that costs something: naming a stale + * instant accepts an assertion whose validity interval has since + * elapsed, because the question asked is whether it was acceptable then, + * and the answer stays yes forever. A live trust decision calls `verify`, + * not this. + */ + verifyAsOf(prepared: PreparedEvidenceRequest, response: RawEvidenceResponse, asOfMillis: number): VerifiedEvidence +} + +/** + * One request, closed and nonce-bearing, before any byte has left the + * process. + * + * There is no constructor exposed to JS: the only way to obtain one is + * `EvidenceClient.prepare`, which mirrors the real type having no public + * constructor of its own either. + */ +export declare class PreparedEvidenceRequest { + /** The nonce this request carries. Retain it with the transaction record. */ + get requestNonce(): string + /** + * The closed verification policy, with the subject set as `prepare` left + * it. + */ + get policyDocument(): any + /** + * `"acceptFirstUse"` or `{ pinned: [{ role, binding }, ...] }`, exactly as + * this request was prepared. + */ + get subjectExpectations(): any +} + +/** + * A signed response, read but not yet judged. + * + * There is no constructor exposed to JS: the real Rust type has no public + * constructor either, so the only way to obtain one is `EvidenceClient.send`. + */ +export declare class RawEvidenceResponse { + /** + * The signed response bytes, exactly as received. Nothing in them has + * been trusted yet; `verify` is what judges them. + */ + get body(): Buffer + /** + * The deployment's opaque identifier for this exchange, for support + * correlation. + */ + get operation(): string | null +} + +/** + * A response that satisfied every expectation. + * + * Unlike the two classes above, this crosses as a plain object: it is a + * terminal result nothing hands back into a later call, so there is no + * single-send flag or unconstructible real type to protect by staying + * opaque. + */ +export interface VerifiedEvidence { + /** The verified payload, serialized field for field with no hand mapping. */ + evidence: any + /** + * The deployment's opaque identifier for the exchange that produced this + * payload. + */ + operation?: string + /** + * The role-bound subject bindings this payload carries. Persist these + * after a first-use acceptance and pass them back as `subjectExpectations: + * { pinned: [...] }` from then on. + */ + pinnedSubjectExpectations: any +} diff --git a/crates/registry-evidence-client-node/index.js b/crates/registry-evidence-client-node/index.js new file mode 100644 index 000000000..b36861995 --- /dev/null +++ b/crates/registry-evidence-client-node/index.js @@ -0,0 +1,705 @@ +// prettier-ignore +/* eslint-disable */ +// @ts-nocheck +/* auto-generated by NAPI-RS */ + +const { readFileSync } = require('fs') +let nativeBinding = null +const loadErrors = [] + +const isMusl = () => { + let musl = false + if (process.platform === 'linux') { + musl = isMuslFromFilesystem() + if (musl === null) { + musl = isMuslFromReport() + } + if (musl === null) { + musl = isMuslFromChildProcess() + } + } + return musl +} + +const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') + +const isMuslFromFilesystem = () => { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') + } catch { + return null + } +} + +const isMuslFromReport = () => { + let report = null + if (process.report && typeof process.report.getReport === 'function') { + process.report.excludeNetwork = true + report = process.report.getReport() + } + if (!report) { + return null + } + if (report.header && report.header.glibcVersionRuntime) { + return false + } + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true + } + } + return false +} + +const isMuslFromChildProcess = () => { + try { + return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') + } catch (e) { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false + } +} + +function requireNative() { + if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { + try { + return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + } catch (err) { + loadErrors.push(err) + } + } else if (process.platform === 'android') { + if (process.arch === 'arm64') { + try { + return require('./evidence-client.android-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-android-arm64') + const bindingPackageVersion = require('@registrystack/evidence-client-android-arm64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./evidence-client.android-arm-eabi.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-android-arm-eabi') + const bindingPackageVersion = require('@registrystack/evidence-client-android-arm-eabi/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) + } + } else if (process.platform === 'win32') { + if (process.arch === 'x64') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { + try { + return require('./evidence-client.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-win32-x64-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./evidence-client.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-win32-x64-msvc') + const bindingPackageVersion = require('@registrystack/evidence-client-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ia32') { + try { + return require('./evidence-client.win32-ia32-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-win32-ia32-msvc') + const bindingPackageVersion = require('@registrystack/evidence-client-win32-ia32-msvc/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./evidence-client.win32-arm64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-win32-arm64-msvc') + const bindingPackageVersion = require('@registrystack/evidence-client-win32-arm64-msvc/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) + } + } else if (process.platform === 'darwin') { + try { + return require('./evidence-client.darwin-universal.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-darwin-universal') + const bindingPackageVersion = require('@registrystack/evidence-client-darwin-universal/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + if (process.arch === 'x64') { + try { + return require('./evidence-client.darwin-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-darwin-x64') + const bindingPackageVersion = require('@registrystack/evidence-client-darwin-x64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./evidence-client.darwin-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-darwin-arm64') + const bindingPackageVersion = require('@registrystack/evidence-client-darwin-arm64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) + } + } else if (process.platform === 'freebsd') { + if (process.arch === 'x64') { + try { + return require('./evidence-client.freebsd-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-freebsd-x64') + const bindingPackageVersion = require('@registrystack/evidence-client-freebsd-x64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./evidence-client.freebsd-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-freebsd-arm64') + const bindingPackageVersion = require('@registrystack/evidence-client-freebsd-arm64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) + } + } else if (process.platform === 'linux') { + if (process.arch === 'x64') { + if (isMusl()) { + try { + return require('./evidence-client.linux-x64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-x64-musl') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-x64-musl/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./evidence-client.linux-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-x64-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm64') { + if (isMusl()) { + try { + return require('./evidence-client.linux-arm64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-arm64-musl') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm64-musl/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./evidence-client.linux-arm64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-arm64-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm64-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm') { + if (isMusl()) { + try { + return require('./evidence-client.linux-arm-musleabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-arm-musleabihf') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm-musleabihf/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./evidence-client.linux-arm-gnueabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-arm-gnueabihf') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm-gnueabihf/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'loong64') { + if (isMusl()) { + try { + return require('./evidence-client.linux-loong64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-loong64-musl') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-loong64-musl/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./evidence-client.linux-loong64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-loong64-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-loong64-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'riscv64') { + if (isMusl()) { + try { + return require('./evidence-client.linux-riscv64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-riscv64-musl') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-riscv64-musl/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./evidence-client.linux-riscv64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-riscv64-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-riscv64-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ppc64') { + try { + return require('./evidence-client.linux-ppc64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-ppc64-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-ppc64-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 's390x') { + try { + return require('./evidence-client.linux-s390x-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-linux-s390x-gnu') + const bindingPackageVersion = require('@registrystack/evidence-client-linux-s390x-gnu/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) + } + } else if (process.platform === 'openharmony') { + if (process.arch === 'arm64') { + try { + return require('./evidence-client.openharmony-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-openharmony-arm64') + const bindingPackageVersion = require('@registrystack/evidence-client-openharmony-arm64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'x64') { + try { + return require('./evidence-client.openharmony-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-openharmony-x64') + const bindingPackageVersion = require('@registrystack/evidence-client-openharmony-x64/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./evidence-client.openharmony-arm.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/evidence-client-openharmony-arm') + const bindingPackageVersion = require('@registrystack/evidence-client-openharmony-arm/package.json').version + if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) + } + } else { + loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) + } +} + +function createLoadErrorChain(errors) { + return errors.reduce((previous, current) => { + let message + try { + message = + current && typeof current.message === 'string' + ? current.message + : String(current) + } catch { + message = 'Unknown error' + } + const error = new Error(message) + error.cause = previous + return error + }, null) +} + +// NAPI_RS_FORCE_WASI is a tri-state flag: +// unset / any other value → native binding preferred, WASI is only a fallback +// 'true' → prefer WASI, but retain native as a lazy fallback +// 'error' → require WASI without initializing a native fallback +// Treating any non-empty string as truthy (the historical behavior) meant +// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered +// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +// +// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict +// WASI loading. It never crosses into another flavor or falls back to native. +const __napiWasiFlavors = ["wasm32-wasi"] +const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR +const __napiWasiFlavorRequested = + typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 +if ( + __napiWasiFlavorRequested && + __napiWasiFlavors.indexOf(__napiWasiFlavor) === -1 +) { + throw new Error( + 'Unsupported WASI flavor "' + + __napiWasiFlavor + + '". Available flavors: ' + + __napiWasiFlavors.join(', '), + ) +} +const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error' +const forceWasi = + process.env.NAPI_RS_FORCE_WASI === 'true' || + forceWasiError || + __napiWasiFlavorRequested + +if (!forceWasi) { + nativeBinding = requireNative() +} + +if (!nativeBinding || forceWasi) { + let wasiBinding = null + let wasiBindingLoaded = false + const wasiBindingErrors = [] + const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => { + try { + require.resolve(specifier) + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + if (isPackage) { + try { + require.resolve(specifier + '/package.json') + } catch (packageError) { + if (packageError && packageError.code === 'MODULE_NOT_FOUND') { + return resolveError + } + // An exports restriction proves the package exists even when its + // package.json is not public. Preserve the root resolution failure. + throw resolveError + } + // The package exists but its main/export target is broken. + throw resolveError + } + return resolveError + } + if (localArtifacts) { + let artifactError = null + for (let i = 0; i < localArtifacts.length; i++) { + try { + require.resolve(localArtifacts[i]) + return null + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + artifactError = resolveError + } + } + return artifactError + } + return null + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('./evidence-client.wasi.cjs', false, ["./evidence-client.wasm32-wasi.debug.wasm","./evidence-client.wasm32-wasi.wasm"]) + candidateFailed = candidateError !== null + if (!candidateFailed) { + wasiBinding = require('./evidence-client.wasi.cjs') + nativeBinding = wasiBinding + wasiBindingLoaded = true + } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('@registrystack/evidence-client-wasm32-wasi', true, undefined) + candidateFailed = candidateError !== null + if (!candidateFailed) { + if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + const bindingPackageVersion = require('@registrystack/evidence-client-wasm32-wasi/package.json').version + if (bindingPackageVersion !== '0.16.3') { + throw new Error(`WASI binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + } + wasiBinding = require('@registrystack/evidence-client-wasm32-wasi') + nativeBinding = wasiBinding + wasiBindingLoaded = true + } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if ( + !wasiBindingLoaded && + forceWasi && + !forceWasiError && + !__napiWasiFlavorRequested + ) { + nativeBinding = requireNative() + } + if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) { + const error = new Error( + __napiWasiFlavorRequested + ? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found' + : 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error', + ) + error.cause = createLoadErrorChain(wasiBindingErrors) + throw error + } +} + +if (!nativeBinding) { + if (loadErrors.length > 0) { + const error = new Error( + `Cannot find native binding. ` + + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', + ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = createLoadErrorChain(loadErrors) + throw error + } + throw new Error(`Failed to load native binding`) +} + +module.exports = nativeBinding +module.exports.EvidenceClient = nativeBinding.EvidenceClient +module.exports.PreparedEvidenceRequest = nativeBinding.PreparedEvidenceRequest +module.exports.RawEvidenceResponse = nativeBinding.RawEvidenceResponse diff --git a/crates/registry-evidence-client-node/package-lock.json b/crates/registry-evidence-client-node/package-lock.json new file mode 100644 index 000000000..5837d8429 --- /dev/null +++ b/crates/registry-evidence-client-node/package-lock.json @@ -0,0 +1,2005 @@ +{ + "name": "@registrystack/evidence-client", + "version": "0.16.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@registrystack/evidence-client", + "version": "0.16.3", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "3.8.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.8.2.tgz", + "integrity": "sha512-iFmp3Lo/ipXoJI1I/6V/xU4hQV42bXS3A7j8C+Wt3LOtYY7HFCilkhyUkN1igLJFXWMICq95kNxhNcwzNRd+Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.0.1", + "@octokit/rest": "^22.0.1", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "emnapi": "2.0.0-alpha.3", + "es-toolkit": "^1.47.0", + "js-yaml": "^4.2.0", + "obug": "^2.1.2", + "semver": "^7.8.2", + "typanion": "^3.14.0", + "typescript": "^6.0.3" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": "^20.17.0 || ^22.13.0 || >= 23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/runtime": "2.0.0-alpha.3" + }, + "peerDependenciesMeta": { + "@emnapi/runtime": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" + }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.5.1.tgz", + "integrity": "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.20 || ^24.12 || >=25" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.5.1", + "@napi-rs/lzma-android-arm64": "1.5.1", + "@napi-rs/lzma-darwin-arm64": "1.5.1", + "@napi-rs/lzma-darwin-x64": "1.5.1", + "@napi-rs/lzma-freebsd-x64": "1.5.1", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", + "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", + "@napi-rs/lzma-linux-arm64-musl": "1.5.1", + "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", + "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", + "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-musl": "1.5.1", + "@napi-rs/lzma-wasm32-wasi": "1.5.1", + "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", + "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", + "@napi-rs/lzma-win32-x64-msvc": "1.5.1" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.5.1.tgz", + "integrity": "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.5.1.tgz", + "integrity": "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.5.1.tgz", + "integrity": "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.5.1.tgz", + "integrity": "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.5.1.tgz", + "integrity": "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.5.1.tgz", + "integrity": "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.5.1.tgz", + "integrity": "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.5.1.tgz", + "integrity": "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.5.1.tgz", + "integrity": "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.5.1.tgz", + "integrity": "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.5.1.tgz", + "integrity": "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.5.1.tgz", + "integrity": "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.5.1.tgz", + "integrity": "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.5.1.tgz", + "integrity": "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.5.1.tgz", + "integrity": "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.5.1.tgz", + "integrity": "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.1.tgz", + "integrity": "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.1", + "@napi-rs/tar-android-arm64": "1.1.1", + "@napi-rs/tar-darwin-arm64": "1.1.1", + "@napi-rs/tar-darwin-x64": "1.1.1", + "@napi-rs/tar-freebsd-x64": "1.1.1", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/tar-linux-arm64-gnu": "1.1.1", + "@napi-rs/tar-linux-arm64-musl": "1.1.1", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", + "@napi-rs/tar-linux-s390x-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-musl": "1.1.1", + "@napi-rs/tar-wasm32-wasi": "1.1.1", + "@napi-rs/tar-win32-arm64-msvc": "1.1.1", + "@napi-rs/tar-win32-ia32-msvc": "1.1.1", + "@napi-rs/tar-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.1.tgz", + "integrity": "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.1.tgz", + "integrity": "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.1.tgz", + "integrity": "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.1.0.tgz", + "integrity": "sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.22.0" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", + "@napi-rs/wasm-tools-android-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-x64": "1.1.0", + "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", + "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.1.0.tgz", + "integrity": "sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.1.0.tgz", + "integrity": "sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz", + "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.4", + "@octokit/request": "^10.0.13", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.4.tgz", + "integrity": "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.4.tgz", + "integrity": "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.13", + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.13.tgz", + "integrity": "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.1.tgz", + "integrity": "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^28.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emnapi": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-2.0.0-alpha.3.tgz", + "integrity": "sha512-K9bc9Xx4OwSfhJpdSOpcfIKzn7/6emuubaIorf6I5e7WBAM79665rf6iHr9y50NL4qYMUP/AheTpD1Z4yU1EBw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "node-addon-api": ">= 6.1.0" + }, + "peerDependenciesMeta": { + "node-addon-api": { + "optional": true + } + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz", + "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/crates/registry-evidence-client-node/package.json b/crates/registry-evidence-client-node/package.json new file mode 100644 index 000000000..6a62e9c1a --- /dev/null +++ b/crates/registry-evidence-client-node/package.json @@ -0,0 +1,32 @@ +{ + "name": "@registrystack/evidence-client", + "version": "0.16.3", + "description": "Node.js binding for the Evidence relying-party client, via napi-rs.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/registrystack/registry-stack" + }, + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "*.node" + ], + "napi": { + "binaryName": "evidence-client" + }, + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "test": "node --test __test__/*.test.js", + "check:types": "napi build --platform --release --dts index.d.ts.check && cmp index.d.ts index.d.ts.check && rm -f index.d.ts.check" + }, + "devDependencies": { + "@napi-rs/cli": "3.8.2" + } +} diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs new file mode 100644 index 000000000..d86d31b34 --- /dev/null +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -0,0 +1,1092 @@ +//! JS-value <-> Rust conversions for the Evidence Node binding. +//! +//! Every function here is a plain Rust function over [`serde_json::Value`], +//! so the whole conversion layer is unit-testable with `cargo test` and +//! carries no dependency on `napi`. `src/lib.rs` is the only file in this +//! crate that touches the `napi`/`napi-derive` crates; it calls into this +//! module for every conversion and reports failures through +//! [`map_client_error`], [`map_conversion_error`], and [`map_config_error`]. + +use std::{fmt, sync::Arc, time::Duration}; + +use registry_evidence_client::{ + AssuranceProfile, Evidence, EvidenceClientConfig, EvidenceClientError, EvidenceRequestSpec, + ExpectedOutputDocument, ExpectedSubjectDocument, JwksDocument, PrivateKeyJwt, + PrivateKeyJwtConfig, SelectorValue, StaticToken, SubjectExpectations, SubjectRequest, + TokenError, TokenProvider, +}; +use registry_platform_crypto::PrivateJwk; +use serde_json::{Map, Value}; +use url::Url; + +/// A JS-supplied value did not have the shape this binding requires. +/// +/// This is distinct from [`EvidenceClientError`]: it is refused before any +/// client-level Rust type exists, so it carries its own message rather than +/// borrowing the fixed `&'static str` reason of a type that cannot describe a +/// dynamically built JS shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversionError(pub String); + +impl ConversionError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ConversionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ConversionError {} + +/// Building a client configuration mixes pure shape conversion with a +/// genuine, semantically real credential construction +/// ([`PrivateKeyJwt::new`]), so a failure may come from either stage. Keeping +/// them distinct lets a caller (and a test) tell "the JS object was malformed" +/// apart from "the configuration it described is unusable." +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + Shape(ConversionError), + Client(EvidenceClientError), +} + +impl From for ConfigError { + fn from(error: ConversionError) -> Self { + Self::Shape(error) + } +} + +impl From for ConfigError { + fn from(error: TokenError) -> Self { + Self::Client(EvidenceClientError::Token(error)) + } +} + +fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a Map, ConversionError> { + value + .as_object() + .ok_or_else(|| ConversionError::new(format!("{what} must be an object"))) +} + +fn required_string(object: &Map, field: &str) -> Result { + object + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a string"))) +} + +fn required_u64(object: &Map, field: &str) -> Result { + object + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a non-negative integer"))) +} + +fn optional_string( + object: &Map, + field: &str, +) -> Result, ConversionError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) => Ok(Some(text.clone())), + Some(_) => Err(ConversionError::new(format!("`{field}` must be a string"))), + } +} + +fn optional_u64(object: &Map, field: &str) -> Result, ConversionError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value.as_u64().map(Some).ok_or_else(|| { + ConversionError::new(format!("`{field}` must be a non-negative integer")) + }), + } +} + +fn optional_i64(object: &Map, field: &str) -> Result, ConversionError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value.as_i64().map(Some).ok_or_else(|| { + ConversionError::new(format!("`{field}` must be an integer that fits in 64 bits")) + }), + } +} + +fn parse_url(value: &str, what: &str) -> Result { + Url::parse(value).map_err(|_| ConversionError::new(format!("{what} must be a valid URL"))) +} + +/// The three scalar shapes a selector value may take on the wire, read off a +/// JS value. +/// +/// A float, an array, `null`, and an integer literal too large for `i64` are +/// all refused here: the request contract's own numeric bound +/// (`MINIMUM_SELECTOR_INTEGER..=MAXIMUM_SELECTOR_INTEGER`) is enforced later, +/// by the real `EvidenceClient::prepare` call, once a genuine +/// `EvidenceRequestSpec` exists. +fn selector_value_from_json(value: &Value) -> Result { + match value { + Value::String(text) => Ok(SelectorValue::from(text.as_str())), + Value::Bool(flag) => Ok(SelectorValue::from(*flag)), + Value::Number(number) => number.as_i64().map(SelectorValue::from).ok_or_else(|| { + ConversionError::new( + "a selector integer value must fit in 64 bits with no fractional part", + ) + }), + _ => Err(ConversionError::new( + "a selector value must be a string, an integer, or a boolean", + )), + } +} + +fn subject_request_from_json(value: &Value) -> Result { + let object = as_object(value, "a subject request")?; + let role = required_string(object, "role")?; + let selector_profile = required_string(object, "selectorProfile")?; + let selector_values = match object.get("selectorValues") { + None | Some(Value::Null) => None, + Some(Value::Object(values)) => { + let mut pairs = Vec::with_capacity(values.len()); + for (name, value) in values { + pairs.push((name.clone(), selector_value_from_json(value)?)); + } + Some(pairs) + } + Some(_) => { + return Err(ConversionError::new( + "`selectorValues` must be an object mapping field names to values", + )) + } + }; + Ok(SubjectRequest { + role, + selector_profile, + selector_values, + }) +} + +/// `subjectExpectations` accepts `{"pinned": [{"role", "binding"}, ...]}` or +/// the literal string `"acceptFirstUse"`. There is no third shape, matching +/// [`SubjectExpectations`] having no third variant. +pub fn subject_expectations_from_json( + value: &Value, +) -> Result { + match value { + Value::String(tag) if tag == "acceptFirstUse" => Ok(SubjectExpectations::AcceptFirstUse), + Value::Object(object) => { + let pinned = object + .get("pinned") + .and_then(Value::as_array) + .ok_or_else(|| { + ConversionError::new("a pinned subject expectation must carry a `pinned` array") + })?; + let mut subjects = Vec::with_capacity(pinned.len()); + for entry in pinned { + let entry = as_object(entry, "a pinned subject expectation")?; + subjects.push(ExpectedSubjectDocument { + role: required_string(entry, "role")?, + binding: required_string(entry, "binding")?, + }); + } + Ok(SubjectExpectations::Pinned(subjects)) + } + _ => Err(ConversionError::new( + "`subjectExpectations` must be \"acceptFirstUse\" or {\"pinned\": [...]}", + )), + } +} + +/// The inverse of [`subject_expectations_from_json`]. Infallible: every +/// [`SubjectExpectations`] value already came from a caller's own request, and +/// both variants have an unambiguous JSON rendering. +pub fn subject_expectations_to_json(expectations: &SubjectExpectations) -> Value { + match expectations { + SubjectExpectations::AcceptFirstUse => Value::String("acceptFirstUse".to_owned()), + SubjectExpectations::Pinned(subjects) => { + let pinned: Vec = subjects + .iter() + .map(|subject| { + serde_json::json!({ + "role": subject.role, + "binding": subject.binding, + }) + }) + .collect(); + serde_json::json!({ "pinned": pinned }) + } + } +} + +fn expected_outputs_from_json( + value: &Value, +) -> Result, ConversionError> { + serde_json::from_value(value.clone()) + .map_err(|error| ConversionError::new(format!("`expectedOutputs` is invalid: {error}"))) +} + +fn assurance_profile_from_json(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|error| { + ConversionError::new(format!("`expectedAssuranceProfile` is invalid: {error}")) + }) +} + +/// Build the specification [`registry_evidence_client::EvidenceClient::prepare`] +/// validates. Only shape is checked here: an empty identifier, an out-of-range +/// count, or any other business rule is the real client's own refusal, raised +/// once a genuine `EvidenceRequestSpec` exists. +pub fn spec_from_json(value: &Value) -> Result { + let object = as_object(value, "a request specification")?; + + let subjects_json = object + .get("subjects") + .and_then(Value::as_array) + .ok_or_else(|| ConversionError::new("`subjects` must be an array"))?; + let subjects = subjects_json + .iter() + .map(subject_request_from_json) + .collect::, _>>()?; + + let expected_outputs_json = object + .get("expectedOutputs") + .ok_or_else(|| ConversionError::new("`expectedOutputs` must be present"))?; + let expected_outputs = expected_outputs_from_json(expected_outputs_json)?; + + let expected_assurance_profile_json = object + .get("expectedAssuranceProfile") + .ok_or_else(|| ConversionError::new("`expectedAssuranceProfile` must be present"))?; + let expected_assurance_profile = assurance_profile_from_json(expected_assurance_profile_json)?; + + let subject_expectations_json = object + .get("subjectExpectations") + .ok_or_else(|| ConversionError::new("`subjectExpectations` must be present"))?; + let subject_expectations = subject_expectations_from_json(subject_expectations_json)?; + + Ok(EvidenceRequestSpec { + requirement: required_string(object, "requirement")?, + purpose: required_string(object, "purpose")?, + audience: required_string(object, "audience")?, + evidence_type: required_string(object, "evidenceType")?, + issued_by: required_string(object, "issuedBy")?, + provided_by: required_string(object, "providedBy")?, + configuration_revision: required_string(object, "configurationRevision")?, + expected_assurance_profile, + subjects, + expected_outputs, + maximum_assertion_lifetime_seconds: required_u64( + object, + "maximumAssertionLifetimeSeconds", + )?, + clock_skew_seconds: required_u64(object, "clockSkewSeconds")?, + subject_expectations, + }) +} + +/// `token.privateKeyJwt`'s own shape mirrors [`PrivateKeyJwtConfig`]'s builder +/// surface: one required endpoint, client identifier, and signing key, plus +/// the same optional knobs the Rust type exposes for its own outbound +/// exchange with the token endpoint. +fn private_key_jwt_provider_from_json(value: &Value) -> Result { + let object = as_object(value, "`token.privateKeyJwt`").map_err(ConfigError::Shape)?; + + let token_endpoint = parse_url( + &required_string(object, "tokenEndpoint").map_err(ConfigError::Shape)?, + "`token.privateKeyJwt.tokenEndpoint`", + ) + .map_err(ConfigError::Shape)?; + let client_id = required_string(object, "clientId").map_err(ConfigError::Shape)?; + + let client_key_json = object.get("clientKey").ok_or_else(|| { + ConfigError::Shape(ConversionError::new( + "`token.privateKeyJwt.clientKey` must be present", + )) + })?; + let client_key_text = serde_json::to_string(client_key_json).map_err(|error| { + ConfigError::Shape(ConversionError::new(format!( + "`token.privateKeyJwt.clientKey` is invalid: {error}" + ))) + })?; + let client_key = PrivateJwk::parse(&client_key_text).map_err(|error| { + ConfigError::Shape(ConversionError::new(format!( + "`token.privateKeyJwt.clientKey` is invalid: {error}" + ))) + })?; + + let mut config = PrivateKeyJwtConfig::new(token_endpoint, client_id, client_key); + if let Some(audience) = optional_string(object, "audience").map_err(ConfigError::Shape)? { + config = config.with_audience(audience); + } + if let Some(seconds) = + optional_i64(object, "assertionLifetimeSeconds").map_err(ConfigError::Shape)? + { + config = config.with_assertion_lifetime_seconds(seconds); + } + if let Some(seconds) = + optional_i64(object, "refreshMarginSeconds").map_err(ConfigError::Shape)? + { + config = config.with_refresh_margin_seconds(seconds); + } + if let Some(millis) = optional_u64(object, "requestTimeoutMs").map_err(ConfigError::Shape)? { + config = config.with_request_timeout(Duration::from_millis(millis)); + } + if let Some(millis) = optional_u64(object, "connectTimeoutMs").map_err(ConfigError::Shape)? { + config = config.with_connect_timeout(Duration::from_millis(millis)); + } + if let Some(user_agent) = optional_string(object, "userAgent").map_err(ConfigError::Shape)? { + config = config.with_user_agent(user_agent); + } + if let Some(pem_bundle) = + optional_string(object, "trustedRootCertificates").map_err(ConfigError::Shape)? + { + config = config.with_trusted_root_certificates(pem_bundle.into_bytes()); + } + + PrivateKeyJwt::new(config).map_err(ConfigError::from) +} + +fn token_provider_from_json( + object: &Map, +) -> Result, ConfigError> { + let token = object + .get("token") + .ok_or_else(|| ConversionError::new("`token` must be present")) + .map_err(ConfigError::Shape)?; + let token_object = as_object(token, "`token`").map_err(ConfigError::Shape)?; + + if let Some(value) = token_object.get("static") { + let value = value + .as_str() + .ok_or_else(|| ConversionError::new("`token.static` must be a string")) + .map_err(ConfigError::Shape)?; + let provider = StaticToken::new(value)?; + return Ok(Arc::new(provider)); + } + if let Some(value) = token_object.get("privateKeyJwt") { + let provider = private_key_jwt_provider_from_json(value)?; + return Ok(Arc::new(provider)); + } + Err(ConfigError::Shape(ConversionError::new( + "`token` must carry exactly one of `static` or `privateKeyJwt`", + ))) +} + +/// Build the configuration [`registry_evidence_client::EvidenceClient::new`] +/// validates. Only shape is checked here (a missing field, a malformed URL, a +/// malformed key); the pinned-key-set, transport, and timeout business rules +/// are the real client's own refusal, raised once a genuine +/// `EvidenceClientConfig` exists. +pub fn config_from_json(value: &Value) -> Result { + let object = as_object(value, "the client configuration").map_err(ConfigError::Shape)?; + + let base_url = parse_url( + &required_string(object, "baseUrl").map_err(ConfigError::Shape)?, + "`baseUrl`", + ) + .map_err(ConfigError::Shape)?; + + let trusted_jwks_json = object + .get("trustedJwks") + .ok_or_else(|| ConfigError::Shape(ConversionError::new("`trustedJwks` must be present")))?; + let trusted_jwks: JwksDocument = + serde_json::from_value(trusted_jwks_json.clone()).map_err(|error| { + ConfigError::Shape(ConversionError::new(format!( + "`trustedJwks` is invalid: {error}" + ))) + })?; + + let token_provider = token_provider_from_json(object)?; + + let mut config = EvidenceClientConfig::new(base_url, token_provider, trusted_jwks); + + if let Some(millis) = optional_u64(object, "requestTimeoutMs").map_err(ConfigError::Shape)? { + config = config.with_request_timeout(Duration::from_millis(millis)); + } + if let Some(millis) = optional_u64(object, "connectTimeoutMs").map_err(ConfigError::Shape)? { + config = config.with_connect_timeout(Duration::from_millis(millis)); + } + if let Some(user_agent) = optional_string(object, "userAgent").map_err(ConfigError::Shape)? { + config = config.with_user_agent(user_agent); + } + if let Some(pem_bundle) = + optional_string(object, "trustedRootCertificates").map_err(ConfigError::Shape)? + { + config = config.with_trusted_root_certificates(pem_bundle.into_bytes()); + } + if let Some(max_bytes) = optional_u64(object, "maxResponseBytes").map_err(ConfigError::Shape)? { + config = config.with_max_response_bytes(max_bytes); + } + + Ok(config) +} + +/// The verified payload crosses to JS through this, never through `Debug`. +pub fn evidence_to_json(evidence: &Evidence) -> Result { + serde_json::to_value(evidence).map_err(|error| { + ConversionError::new(format!( + "the verified evidence payload could not be serialized: {error}" + )) + }) +} + +/// A shape-level failure reports the same stable envelope every mapped +/// failure uses, so a caller need not special-case where a failure +/// originated. There is no dedicated "shape" kind among the eight the runtime +/// client defines; a JS caller that supplied an unusable shape is, from the +/// caller's side, exactly the "the client cannot be used as configured" case. +pub fn map_conversion_error(error: &ConversionError) -> Value { + serde_json::json!({ + "kind": "configuration", + "message": error.to_string(), + }) +} + +pub fn map_config_error(error: &ConfigError) -> Value { + match error { + ConfigError::Shape(shape) => map_conversion_error(shape), + ConfigError::Client(client) => map_client_error(client), + } +} + +/// Map any [`EvidenceClientError`] to the stable JSON envelope described in +/// the crate's `AGENTS.md`-linked design: `kind` and `message` always, plus +/// whichever of `status`, `code`, `operation`, `retryAfterSeconds`, and +/// `transportKind` the variant carries. `code` is deliberately overloaded: a +/// `Denied`/`Protocol` wire code, a `Token::Refused` OAuth code, and a +/// `Verification` failure's own kind string all travel in the same member, +/// since a caller branches on `kind` first and `code` only refines it. +/// +/// Never included: response bytes, a credential, a header value, a selector +/// value, or a subject binding. Every message here is `Display` text over +/// fixed, non-secret reasons; none of the eight kinds can carry one of those. +pub fn map_client_error(error: &EvidenceClientError) -> Value { + let mut fields = Map::new(); + fields.insert("kind".to_owned(), Value::String(error.kind().to_owned())); + fields.insert("message".to_owned(), Value::String(error.to_string())); + + match error { + EvidenceClientError::Configuration { .. } | EvidenceClientError::Nonce(_) => {} + EvidenceClientError::Token(token_error) => insert_token_fields(&mut fields, token_error), + EvidenceClientError::Transport { kind } => { + fields.insert( + "transportKind".to_owned(), + Value::String(kind.kind().to_owned()), + ); + } + EvidenceClientError::Denied { + status, + code, + operation, + retry_after_seconds, + } => { + fields.insert("status".to_owned(), Value::from(*status)); + fields.insert("code".to_owned(), Value::String(code.clone())); + insert_operation(&mut fields, operation); + insert_retry_after(&mut fields, *retry_after_seconds); + } + EvidenceClientError::NotAvailable { operation } => { + insert_operation(&mut fields, operation); + } + EvidenceClientError::Protocol { + status, + code, + operation, + retry_after_seconds, + } => { + fields.insert("status".to_owned(), Value::from(*status)); + if let Some(code) = code { + fields.insert("code".to_owned(), Value::String(code.clone())); + } + insert_operation(&mut fields, operation); + insert_retry_after(&mut fields, *retry_after_seconds); + } + EvidenceClientError::Verification(verification_error) => { + fields.insert( + "code".to_owned(), + Value::String(verification_error.kind().to_owned()), + ); + } + // `EvidenceClientError` is `#[non_exhaustive]`: a variant this crate + // does not yet know about still maps, with only `kind` and `message`. + _ => {} + } + + Value::Object(fields) +} + +fn insert_token_fields(fields: &mut Map, error: &TokenError) { + match error { + TokenError::Unavailable | TokenError::Invalid { .. } | TokenError::Configuration { .. } => { + } + TokenError::Transport { kind } => { + fields.insert( + "transportKind".to_owned(), + Value::String(kind.kind().to_owned()), + ); + } + TokenError::Refused { code } => { + fields.insert("code".to_owned(), Value::String(code.as_str().to_owned())); + } + TokenError::Protocol { status } => { + fields.insert("status".to_owned(), Value::from(*status)); + } + // `TokenError` is `#[non_exhaustive]`. + _ => {} + } +} + +fn insert_operation(fields: &mut Map, operation: &Option) { + if let Some(operation) = operation { + fields.insert("operation".to_owned(), Value::String(operation.clone())); + } +} + +fn insert_retry_after(fields: &mut Map, retry_after_seconds: Option) { + if let Some(seconds) = retry_after_seconds { + fields.insert("retryAfterSeconds".to_owned(), Value::from(seconds)); + } +} + +#[cfg(test)] +mod tests { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use ed25519_dalek::SigningKey; + use registry_evidence_client::{ + EvidenceObjectType, OAuthErrorCode, SubjectBinding, SupportedValue, TransportKind, + VerificationError, + }; + + use super::*; + + // --- selector_value_from_json --- + + #[test] + fn a_string_selector_value_converts() { + assert_eq!( + selector_value_from_json(&Value::String("synthetic-record-001".to_owned())).unwrap(), + SelectorValue::from("synthetic-record-001") + ); + } + + #[test] + fn a_boolean_selector_value_converts() { + assert_eq!( + selector_value_from_json(&Value::Bool(true)).unwrap(), + SelectorValue::from(true) + ); + } + + #[test] + fn an_integer_selector_value_converts() { + assert_eq!( + selector_value_from_json(&serde_json::json!(7)).unwrap(), + SelectorValue::from(7_i64) + ); + } + + #[test] + fn a_selector_value_outside_the_accepted_shapes_is_refused() { + for value in [ + serde_json::json!(1.5), + serde_json::json!([1, 2, 3]), + Value::Null, + // i64::MAX + 1: a valid JSON integer, but not one `i64` can hold. + serde_json::json!(9_223_372_036_854_775_808_u64), + ] { + assert!( + selector_value_from_json(&value).is_err(), + "{value} was accepted" + ); + } + } + + // --- subject_expectations_from_json / _to_json --- + + #[test] + fn accept_first_use_round_trips() { + let value = Value::String("acceptFirstUse".to_owned()); + let expectations = subject_expectations_from_json(&value).expect("the shape is accepted"); + assert!(matches!(expectations, SubjectExpectations::AcceptFirstUse)); + assert_eq!(subject_expectations_to_json(&expectations), value); + } + + #[test] + fn pinned_subject_expectations_round_trip() { + let value = serde_json::json!({ + "pinned": [{"role": "subject", "binding": "y0KMdWluZGluZw"}], + }); + let expectations = subject_expectations_from_json(&value).expect("the shape is accepted"); + let SubjectExpectations::Pinned(subjects) = &expectations else { + panic!("expected a pinned subject expectation"); + }; + assert_eq!(subjects.len(), 1); + assert_eq!(subjects[0].role, "subject"); + assert_eq!(subjects[0].binding, "y0KMdWluZGluZw"); + assert_eq!(subject_expectations_to_json(&expectations), value); + } + + #[test] + fn a_subject_expectation_outside_the_two_accepted_shapes_is_refused() { + for value in [ + Value::String("something-else".to_owned()), + serde_json::json!({}), + serde_json::json!({"pinned": [{"role": "subject"}]}), + serde_json::json!(1), + Value::Null, + ] { + assert!( + subject_expectations_from_json(&value).is_err(), + "{value} was accepted" + ); + } + } + + // --- spec_from_json --- + + fn valid_spec_json() -> Value { + serde_json::json!({ + "requirement": "urn:example:client:requirement:status:v1", + "purpose": "example-decision", + "audience": "urn:example:client:audience:relying-party", + "evidenceType": "urn:example:client:evidence-type:status:v1", + "issuedBy": "urn:example:client:issuer", + "providedBy": "urn:example:client:provider", + "configurationRevision": "sha256:00", + "expectedAssuranceProfile": "local", + "subjects": [{ + "role": "subject", + "selectorProfile": "record-lookup-v1", + "selectorValues": { + "record_reference": "synthetic-record-001", + }, + }], + "expectedOutputs": [{ + "concept": "urn:example:client:concept:status-holds", + "form": "boolean", + }], + "maximumAssertionLifetimeSeconds": 300, + "clockSkewSeconds": 60, + "subjectExpectations": "acceptFirstUse", + }) + } + + #[test] + fn a_well_formed_specification_converts_in_full() { + let spec = spec_from_json(&valid_spec_json()).expect("the specification is accepted"); + assert_eq!(spec.requirement, "urn:example:client:requirement:status:v1"); + assert_eq!(spec.expected_assurance_profile, AssuranceProfile::Local); + assert_eq!(spec.subjects.len(), 1); + assert_eq!(spec.subjects[0].role, "subject"); + assert_eq!( + spec.subjects[0].selector_values.as_ref().unwrap()[0].0, + "record_reference" + ); + assert_eq!(spec.expected_outputs.len(), 1); + assert_eq!(spec.maximum_assertion_lifetime_seconds, 300); + assert_eq!(spec.clock_skew_seconds, 60); + assert!(matches!( + spec.subject_expectations, + SubjectExpectations::AcceptFirstUse + )); + } + + #[test] + fn a_specification_missing_any_required_field_is_refused() { + let required_fields = [ + "requirement", + "purpose", + "audience", + "evidenceType", + "issuedBy", + "providedBy", + "configurationRevision", + "expectedAssuranceProfile", + "subjects", + "expectedOutputs", + "maximumAssertionLifetimeSeconds", + "clockSkewSeconds", + "subjectExpectations", + ]; + for field in required_fields { + let mut spec = valid_spec_json(); + spec.as_object_mut().unwrap().remove(field); + assert!( + spec_from_json(&spec).is_err(), + "missing `{field}` was accepted" + ); + } + } + + #[test] + fn a_specification_that_is_not_an_object_is_refused() { + assert!(spec_from_json(&Value::Null).is_err()); + assert!(spec_from_json(&serde_json::json!([])).is_err()); + } + + // --- config_from_json --- + + fn one_key_jwks_json() -> Value { + serde_json::json!({ + "keys": [{ + "kty": "OKP", + "crv": "Ed25519", + "kid": "test-key", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }], + }) + } + + /// A fresh Ed25519 signing key, generated for one test rather than + /// committed to the tree. + fn generated_client_key_json(key_id: &str) -> Value { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the test host supplies randomness"); + let key = SigningKey::from_bytes(&seed); + serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": key_id, + "x": URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(key.to_bytes()), + }) + } + + fn valid_config_json_with_static_token() -> Value { + serde_json::json!({ + "baseUrl": "https://evidence.example.org", + "trustedJwks": one_key_jwks_json(), + "token": { "static": "header-safe-token" }, + }) + } + + #[test] + fn a_configuration_with_a_static_token_converts() { + let config = + config_from_json(&valid_config_json_with_static_token()).expect("the config converts"); + assert_eq!(config.base_url().as_str(), "https://evidence.example.org/"); + assert_eq!(config.trusted_jwks().keys.len(), 1); + } + + #[test] + fn a_configuration_with_a_private_key_jwt_token_converts() { + let config_json = serde_json::json!({ + "baseUrl": "https://evidence.example.org", + "trustedJwks": one_key_jwks_json(), + "token": { + "privateKeyJwt": { + "tokenEndpoint": "https://issuer.example.org/token", + "clientId": "example-client", + "clientKey": generated_client_key_json("signing-key-1"), + "audience": "https://issuer.example.org/", + }, + }, + }); + config_from_json(&config_json).expect("the config converts"); + } + + #[test] + fn a_missing_trusted_jwks_is_a_shape_error() { + let mut config = valid_config_json_with_static_token(); + config.as_object_mut().unwrap().remove("trustedJwks"); + assert!(matches!( + config_from_json(&config), + Err(ConfigError::Shape(_)) + )); + } + + #[test] + fn a_missing_token_is_a_shape_error() { + let mut config = valid_config_json_with_static_token(); + config.as_object_mut().unwrap().remove("token"); + assert!(matches!( + config_from_json(&config), + Err(ConfigError::Shape(_)) + )); + } + + #[test] + fn an_unparseable_base_url_is_a_shape_error() { + let mut config = valid_config_json_with_static_token(); + config["baseUrl"] = Value::String("not a url".to_owned()); + assert!(matches!( + config_from_json(&config), + Err(ConfigError::Shape(_)) + )); + } + + #[test] + fn a_malformed_client_key_is_a_shape_error() { + let config_json = serde_json::json!({ + "baseUrl": "https://evidence.example.org", + "trustedJwks": one_key_jwks_json(), + "token": { + "privateKeyJwt": { + "tokenEndpoint": "https://issuer.example.org/token", + "clientId": "example-client", + // Missing every member a JWK needs. + "clientKey": {}, + }, + }, + }); + assert!(matches!( + config_from_json(&config_json), + Err(ConfigError::Shape(_)) + )); + } + + /// A well-shaped `privateKeyJwt` block can still describe a configuration + /// `PrivateKeyJwt::new` itself refuses. That refusal is a genuine + /// `TokenError`, surfaced as `ConfigError::Client` with `kind: "token"`, + /// not a shape error. + #[test] + fn a_semantically_invalid_private_key_jwt_configuration_is_a_client_error() { + let config_json = serde_json::json!({ + "baseUrl": "https://evidence.example.org", + "trustedJwks": one_key_jwks_json(), + "token": { + "privateKeyJwt": { + "tokenEndpoint": "https://issuer.example.org/token", + "clientId": "example-client", + "clientKey": generated_client_key_json("signing-key-1"), + // The contract accepts 1..=300 seconds. + "assertionLifetimeSeconds": 0, + }, + }, + }); + let error = config_from_json(&config_json).expect_err("the configuration is refused"); + let ConfigError::Client(client_error) = &error else { + panic!("expected a client-level refusal, got {error:?}"); + }; + assert_eq!(client_error.kind(), "token"); + assert_eq!(map_config_error(&error)["kind"], "token"); + } + + // --- evidence_to_json --- + + fn minimal_evidence() -> Evidence { + Evidence { + schema: "https://registrystack.example/evidence/v1".to_owned(), + assurance_profile: AssuranceProfile::Local, + request_nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(), + id: "urn:example:evidence:1".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:client:requirement:status:v1".to_owned(), + is_conformant_to: "urn:example:client:evidence-type:status:v1".to_owned(), + issued_by: "urn:example:client:issuer".to_owned(), + provided_by: "urn:example:client:provider".to_owned(), + issued_at: "2026-01-01T00:00:00Z".to_owned(), + observed_at: "2026-01-01T00:00:00Z".to_owned(), + valid_until: "2026-01-01T00:05:00Z".to_owned(), + purpose: "example-decision".to_owned(), + audience: "urn:example:client:audience:relying-party".to_owned(), + configuration_revision: "sha256:00".to_owned(), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: "y0KMdWluZGluZw".to_owned(), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:client:concept:status-holds".to_owned(), + value: registry_evidence_client::PublicValue::Boolean(true), + }], + } + } + + #[test] + fn evidence_converts_to_the_expected_json_shape() { + let json = evidence_to_json(&minimal_evidence()).expect("evidence serializes"); + // `EvidenceObjectType` has no `rename_all` of its own, so its one + // variant serializes as the Rust identifier itself. + assert_eq!(json["type"], "Evidence"); + assert_eq!(json["assuranceProfile"], "local"); + assert_eq!( + json["supportsRequirement"], + "urn:example:client:requirement:status:v1" + ); + assert_eq!(json["subjects"][0]["role"], "subject"); + assert_eq!(json["subjects"][0]["binding"], "y0KMdWluZGluZw"); + assert_eq!( + json["supportedValues"][0]["providesValueFor"], + "urn:example:client:concept:status-holds" + ); + assert_eq!(json["supportedValues"][0]["value"], true); + } + + // --- map_client_error: one case per stable kind --- + + #[test] + fn a_configuration_failure_carries_only_kind_and_message() { + let error = EvidenceClientError::Configuration { + reason: "the client cannot be used this way", + }; + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "configuration"); + assert!(mapped["message"] + .as_str() + .unwrap() + .contains("the client cannot be used this way")); + assert_eq!(mapped.as_object().unwrap().len(), 2); + } + + #[test] + fn a_nonce_failure_carries_only_kind_and_message() { + let error = EvidenceClientError::Nonce(registry_evidence_client::NonceError::Entropy); + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "nonce"); + assert_eq!(mapped.as_object().unwrap().len(), 2); + } + + #[test] + fn a_transport_failure_carries_its_transport_kind() { + let error = EvidenceClientError::Transport { + kind: TransportKind::Timeout, + }; + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "transport"); + assert_eq!(mapped["transportKind"], "timeout"); + assert_eq!(mapped.as_object().unwrap().len(), 3); + } + + #[test] + fn a_denied_failure_carries_status_code_operation_and_retry_after() { + let error = EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: Some("01JZZZOPERATION".to_owned()), + retry_after_seconds: Some(30), + }; + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "denied"); + assert_eq!(mapped["status"], 403); + assert_eq!(mapped["code"], "not_authorized"); + assert_eq!(mapped["operation"], "01JZZZOPERATION"); + assert_eq!(mapped["retryAfterSeconds"], 30); + } + + #[test] + fn a_denied_failure_with_no_operation_or_retry_after_omits_them() { + let error = EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: None, + retry_after_seconds: None, + }; + let mapped = map_client_error(&error); + assert!(mapped.get("operation").is_none()); + assert!(mapped.get("retryAfterSeconds").is_none()); + } + + #[test] + fn a_not_available_failure_carries_only_its_operation() { + let error = EvidenceClientError::NotAvailable { + operation: Some("01JZZZOPERATION".to_owned()), + }; + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "not_available"); + assert_eq!(mapped["operation"], "01JZZZOPERATION"); + assert_eq!(mapped.as_object().unwrap().len(), 3); + } + + #[test] + fn a_protocol_failure_carries_status_and_its_optional_members() { + let error = EvidenceClientError::Protocol { + status: 503, + code: Some("temporarily_unavailable".to_owned()), + operation: Some("01JZZZOPERATION".to_owned()), + retry_after_seconds: Some(5), + }; + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "protocol"); + assert_eq!(mapped["status"], 503); + assert_eq!(mapped["code"], "temporarily_unavailable"); + assert_eq!(mapped["operation"], "01JZZZOPERATION"); + assert_eq!(mapped["retryAfterSeconds"], 5); + } + + #[test] + fn a_protocol_failure_with_no_code_omits_it() { + let error = EvidenceClientError::Protocol { + status: 200, + code: None, + operation: None, + retry_after_seconds: None, + }; + let mapped = map_client_error(&error); + assert_eq!(mapped["status"], 200); + assert!(mapped.get("code").is_none()); + } + + #[test] + fn a_verification_failure_carries_its_verifier_kind_as_the_code() { + let error = EvidenceClientError::Verification(VerificationError::Signature); + let mapped = map_client_error(&error); + assert_eq!(mapped["kind"], "verification"); + assert_eq!(mapped["code"], "signature"); + } + + #[test] + fn a_token_failure_nests_its_own_sub_kind_details_under_the_token_kind() { + let unavailable = map_client_error(&EvidenceClientError::Token(TokenError::Unavailable)); + assert_eq!(unavailable["kind"], "token"); + assert_eq!(unavailable.as_object().unwrap().len(), 2); + + let transport = map_client_error(&EvidenceClientError::Token(TokenError::Transport { + kind: TransportKind::Connect, + })); + assert_eq!(transport["kind"], "token"); + assert_eq!(transport["transportKind"], "connect"); + + let refused = map_client_error(&EvidenceClientError::Token(TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + })); + assert_eq!(refused["kind"], "token"); + assert_eq!(refused["code"], "invalid_client"); + + let protocol = map_client_error(&EvidenceClientError::Token(TokenError::Protocol { + status: 500, + })); + assert_eq!(protocol["kind"], "token"); + assert_eq!(protocol["status"], 500); + } + + /// The discriminant is what a caller branches on, so every one of the + /// eight stable kinds is distinct. + #[test] + fn every_client_failure_reports_a_distinct_kind() { + let errors = [ + EvidenceClientError::Configuration { reason: "unusable" }, + EvidenceClientError::Nonce(registry_evidence_client::NonceError::Entropy), + EvidenceClientError::Token(TokenError::Unavailable), + EvidenceClientError::Transport { + kind: TransportKind::Connect, + }, + EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: None, + retry_after_seconds: None, + }, + EvidenceClientError::NotAvailable { operation: None }, + EvidenceClientError::Protocol { + status: 200, + code: None, + operation: None, + retry_after_seconds: None, + }, + EvidenceClientError::Verification(VerificationError::Signature), + ]; + let kinds: std::collections::BTreeSet = errors + .iter() + .map(|error| map_client_error(error)["kind"].as_str().unwrap().to_owned()) + .collect(); + assert_eq!(kinds.len(), errors.len(), "two variants share a kind"); + } + + #[test] + fn a_conversion_error_maps_to_the_configuration_kind() { + let mapped = map_conversion_error(&ConversionError::new("bad shape")); + assert_eq!(mapped["kind"], "configuration"); + assert_eq!(mapped["message"], "bad shape"); + } +} diff --git a/crates/registry-evidence-client-node/src/lib.rs b/crates/registry-evidence-client-node/src/lib.rs new file mode 100644 index 000000000..71e6ee10d --- /dev/null +++ b/crates/registry-evidence-client-node/src/lib.rs @@ -0,0 +1,326 @@ +//! Node.js binding for the Evidence relying-party client, via napi-rs. +//! +//! This crate is a thin `#[napi]` surface only: every JS-value <-> Rust +//! conversion lives in [`convert`], as plain functions over +//! [`serde_json::Value`] with no `napi` dependency of their own, so the +//! conversion layer is unit-testable with `cargo test`. Every Evidence +//! semantic decision (evaluation, signing, verification) is the +//! `registry-evidence-client` crate's own; this crate re-implements none of +//! it. +//! +//! `PreparedEvidenceRequest` and `RawEvidenceResponse` cross as opaque classes +//! wrapping an `Arc` around the real Rust value: neither real type is `Clone` +//! constructible from JS-supplied data (`PreparedEvidenceRequest` is +//! deliberately `!Clone` to protect its single-send flag; `RawEvidenceResponse` +//! has no public constructor at all), and both are produced by one call +//! (`prepare`, `send`) and consumed by a later one (`send`/`verify`, `verify`). +//! An `Arc` clone is cheap and, for `PreparedEvidenceRequest`, preserves the +//! identity of the interior `AtomicBool` the single-send guard checks: cloning +//! the `Arc` shares the flag rather than resetting it. +//! +//! `send` and `requestAndVerify` cannot be plain `async fn` methods that take +//! a class reference as a parameter: napi-rs's tokio bridge requires the whole +//! generated future to be `Send + 'static`, and a class reference into a JS +//! object (`Reference`) is documented as not `Send`. Both methods are +//! instead ordinary (non-async) `#[napi]` functions that clone the `Arc`s they +//! need synchronously, then hand an `async move` block built from only those +//! owned clones to [`napi::Env::spawn_future`]. +#![deny(unsafe_code)] + +mod convert; + +use std::sync::Arc; + +// `napi::Result` is imported unaliased (shadowing the prelude's +// `std::result::Result`, the standard convention in napi-rs bindings): +// napi-derive detects a fallible `#[napi]` return type by checking that the +// return type's own final path segment is literally named `Result`, so an +// aliased name here would silently defeat that detection. +use napi::{ + bindgen_prelude::{Buffer, Env, PromiseRaw}, + Error as NapiError, Result, +}; +use napi_derive::napi; +use registry_evidence_client::{ + EvidenceClient as RealEvidenceClient, PreparedEvidenceRequest as RealPreparedEvidenceRequest, + RawEvidenceResponse as RealRawEvidenceResponse, VerifiedEvidence as RealVerifiedEvidence, +}; + +use convert::{ + config_from_json, evidence_to_json, map_client_error, map_config_error, map_conversion_error, + spec_from_json, subject_expectations_to_json, +}; + +/// Every mapped failure (see `convert::map_client_error` and friends) carries +/// this JSON envelope as the thrown error's message, so a caller can +/// `JSON.parse(error.message)` and branch on `kind`. This is the one place +/// that JSON value becomes a `napi::Error`. +fn to_napi_error(value: serde_json::Value) -> NapiError { + let message = serde_json::to_string(&value).unwrap_or_else(|_| { + r#"{"kind":"configuration","message":"the failure could not be described"}"#.to_owned() + }); + NapiError::from_reason(message) +} + +/// A serialization failure on a value this crate itself constructed (a +/// definitions document, a policy document, a verified payload) is not a +/// caller mistake; it has no `kind` of its own among the eight stable ones, so +/// it is reported as a plain reason rather than forced into that envelope. +fn to_napi_serialization_error(what: &str, error: serde_json::Error) -> NapiError { + NapiError::from_reason(format!("{what} could not be described: {error}")) +} + +/// One request, closed and nonce-bearing, before any byte has left the +/// process. +/// +/// There is no constructor exposed to JS: the only way to obtain one is +/// `EvidenceClient.prepare`, which mirrors the real type having no public +/// constructor of its own either. +#[napi] +pub struct PreparedEvidenceRequest { + inner: Arc, +} + +#[napi] +impl PreparedEvidenceRequest { + /// The nonce this request carries. Retain it with the transaction record. + #[napi(getter)] + pub fn request_nonce(&self) -> String { + self.inner.request_nonce().to_owned() + } + + /// The closed verification policy, with the subject set as `prepare` left + /// it. + #[napi(getter)] + pub fn policy_document(&self) -> Result { + serde_json::to_value(self.inner.policy_document()) + .map_err(|error| to_napi_serialization_error("the policy document", error)) + } + + /// `"acceptFirstUse"` or `{ pinned: [{ role, binding }, ...] }`, exactly as + /// this request was prepared. + #[napi(getter)] + pub fn subject_expectations(&self) -> serde_json::Value { + subject_expectations_to_json(self.inner.subject_expectations()) + } +} + +/// A signed response, read but not yet judged. +/// +/// There is no constructor exposed to JS: the real Rust type has no public +/// constructor either, so the only way to obtain one is `EvidenceClient.send`. +#[napi] +pub struct RawEvidenceResponse { + inner: Arc, +} + +#[napi] +impl RawEvidenceResponse { + /// The signed response bytes, exactly as received. Nothing in them has + /// been trusted yet; `verify` is what judges them. + #[napi(getter)] + pub fn body(&self) -> Buffer { + self.inner.body().to_vec().into() + } + + /// The deployment's opaque identifier for this exchange, for support + /// correlation. + #[napi(getter)] + pub fn operation(&self) -> Option { + self.inner.operation().map(str::to_owned) + } +} + +/// A response that satisfied every expectation. +/// +/// Unlike the two classes above, this crosses as a plain object: it is a +/// terminal result nothing hands back into a later call, so there is no +/// single-send flag or unconstructible real type to protect by staying +/// opaque. +#[napi(object)] +pub struct VerifiedEvidence { + /// The verified payload, serialized field for field with no hand mapping. + pub evidence: serde_json::Value, + /// The deployment's opaque identifier for the exchange that produced this + /// payload. + pub operation: Option, + /// The role-bound subject bindings this payload carries. Persist these + /// after a first-use acceptance and pass them back as `subjectExpectations: + /// { pinned: [...] }` from then on. + pub pinned_subject_expectations: serde_json::Value, +} + +fn verified_evidence_to_napi(verified: &RealVerifiedEvidence) -> Result { + let evidence = evidence_to_json(verified.evidence()) + .map_err(|error| to_napi_error(map_conversion_error(&error)))?; + let pinned_subject_expectations = serde_json::to_value(verified.pinned_subject_expectations()) + .map_err(|error| to_napi_serialization_error("the pinned subject expectations", error))?; + Ok(VerifiedEvidence { + evidence, + operation: verified.operation().map(str::to_owned), + pinned_subject_expectations, + }) +} + +/// A relying party's connection to one Evidence deployment. +#[napi] +pub struct EvidenceClient { + inner: Arc, +} + +#[napi] +impl EvidenceClient { + /// Build a client for one deployment. `trustedJwks` is mandatory; an empty + /// key set is refused, exactly as the Rust configuration is. + #[napi(constructor)] + pub fn new(config: serde_json::Value) -> Result { + let config = + config_from_json(&config).map_err(|error| to_napi_error(map_config_error(&error)))?; + let client = RealEvidenceClient::new(config) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + Ok(Self { + inner: Arc::new(client), + }) + } + + /// Close the expectations for one request and generate its nonce. No I/O + /// happens here. The returned request is good for exactly one exchange: + /// spend it with `send` or `requestAndVerify`. + #[napi] + pub fn prepare(&self, spec: serde_json::Value) -> Result { + let spec = + spec_from_json(&spec).map_err(|error| to_napi_error(map_conversion_error(&error)))?; + let prepared = self + .inner + .prepare(spec) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + Ok(PreparedEvidenceRequest { + inner: Arc::new(prepared), + }) + } + + /// Read the request shapes this requester is entitled to send. Discovery + /// is authoring input, not a trust anchor: it never supplies verification + /// expectations for a request already in flight. + #[napi] + pub async fn discover(&self) -> Result { + let document = self + .inner + .discover() + .await + .map_err(|error| to_napi_error(map_client_error(&error)))?; + serde_json::to_value(&document) + .map_err(|error| to_napi_serialization_error("the definitions document", error)) + } + + /// Read the deployment's published verification key set, for an + /// out-of-band pinning workflow. Verification never calls this: a key set + /// fetched from the same origin as the response it would verify + /// establishes nothing. + #[napi] + pub async fn fetch_jwks(&self) -> Result { + let document = self + .inner + .fetch_jwks() + .await + .map_err(|error| to_napi_error(map_client_error(&error)))?; + serde_json::to_value(&document) + .map_err(|error| to_napi_serialization_error("the key set", error)) + } + + /// Send one prepared request and read the signed response. + /// + /// `prepared` allows exactly one send: a second call with the same object + /// rejects with the configuration failure the Rust layer already + /// produces, without reaching the deployment. Retrying means preparing + /// again, for a fresh nonce. + #[napi(ts_return_type = "Promise")] + pub fn send<'env>( + &self, + env: &'env Env, + prepared: &PreparedEvidenceRequest, + ) -> Result> { + let client = Arc::clone(&self.inner); + let prepared = Arc::clone(&prepared.inner); + env.spawn_future(async move { + client + .send(&prepared) + .await + .map(|response| RawEvidenceResponse { + inner: Arc::new(response), + }) + .map_err(|error| to_napi_error(map_client_error(&error))) + }) + } + + /// Verify a signed response against the policy its request closed, as of + /// now. The trusted key set is the one pinned at construction, always. + /// + /// Unlike sending, verifying is unrestricted: it is offline and + /// idempotent, so a retained response may be re-verified against a + /// retained prepared request as often as needed, including after the + /// single send has been spent. + #[napi] + pub fn verify( + &self, + prepared: &PreparedEvidenceRequest, + response: &RawEvidenceResponse, + ) -> Result { + let verified = self + .inner + .verify(&prepared.inner, &response.inner) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + verified_evidence_to_napi(&verified) + } + + /// Request evidence and verify it in one step. This spends the single + /// send `prepared` allows, exactly as `send` does, so calling it twice + /// with one prepared request fails locally on the second call. + #[napi(ts_return_type = "Promise")] + pub fn request_and_verify<'env>( + &self, + env: &'env Env, + prepared: &PreparedEvidenceRequest, + ) -> Result> { + let client = Arc::clone(&self.inner); + let prepared = Arc::clone(&prepared.inner); + env.spawn_future(async move { + let verified = client + .request_and_verify(&prepared) + .await + .map_err(|error| to_napi_error(map_client_error(&error)))?; + verified_evidence_to_napi(&verified) + }) + } + + /// Verify a retained response as of an explicit instant, given as + /// milliseconds since the Unix epoch. + /// + /// `verify` judges a response against the current clock, which is right + /// when the response has just arrived. This variant names the instant + /// instead, for re-verifying a retained response or replaying a retained + /// transaction record at the instant the original decision was made. + /// + /// A past instant is the direction that costs something: naming a stale + /// instant accepts an assertion whose validity interval has since + /// elapsed, because the question asked is whether it was acceptable then, + /// and the answer stays yes forever. A live trust decision calls `verify`, + /// not this. + #[napi] + pub fn verify_as_of( + &self, + prepared: &PreparedEvidenceRequest, + response: &RawEvidenceResponse, + as_of_millis: f64, + ) -> Result { + let millis = as_of_millis as i64; + let now = chrono::DateTime::from_timestamp_millis(millis).ok_or_else(|| { + NapiError::from_reason("`asOfMillis` is not a representable instant".to_owned()) + })?; + let verified = self + .inner + .verify_as_of(&prepared.inner, &response.inner, now) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + verified_evidence_to_napi(&verified) + } +} diff --git a/crates/registry-evidence-client-node/tests/fixtures/jwks.json b/crates/registry-evidence-client-node/tests/fixtures/jwks.json new file mode 100644 index 000000000..202b59fc8 --- /dev/null +++ b/crates/registry-evidence-client-node/tests/fixtures/jwks.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "alg": "EdDSA", + "crv": "Ed25519", + "kid": "evidence-node-fixture-key-1", + "kty": "OKP", + "x": "jntG5oqYpjSgzgWRRFfA_jv6LrTWTu15HAXzM99IVIo" + } + ] +} diff --git a/crates/registry-evidence-client-node/tests/fixtures/policy.json b/crates/registry-evidence-client-node/tests/fixtures/policy.json new file mode 100644 index 000000000..c16615da0 --- /dev/null +++ b/crates/registry-evidence-client-node/tests/fixtures/policy.json @@ -0,0 +1,25 @@ +{ + "expectedAssuranceProfile": "local", + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "requirement": "urn:example:requirement:v1", + "evidenceType": "urn:example:evidence-type:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "configurationRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "requestNonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expectedSubjects": [ + { + "role": "subject", + "binding": "urn:evidence:subject:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "expectedOutputs": [ + { + "concept": "urn:example:concept:status-holds", + "form": "boolean" + } + ], + "maximumAssertionLifetimeSeconds": 315360000, + "clockSkewSeconds": 30 +} diff --git a/crates/registry-evidence-client-node/tests/fixtures/response.jws.json b/crates/registry-evidence-client-node/tests/fixtures/response.jws.json new file mode 100644 index 000000000..03421dd92 --- /dev/null +++ b/crates/registry-evidence-client-node/tests/fixtures/response.jws.json @@ -0,0 +1,5 @@ +{ + "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2aWRlbmNlLW5vZGUtZml4dHVyZS1rZXktMSIsInR5cCI6ImV2aWRlbmNlK2p3cyIsImN0eSI6ImFwcGxpY2F0aW9uL2V2aWRlbmNlK2pzb24ifQ", + "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpub2RlLWZpeHR1cmUiLCJ0eXBlIjoiRXZpZGVuY2UiLCJzdXBwb3J0c1JlcXVpcmVtZW50IjoidXJuOmV4YW1wbGU6cmVxdWlyZW1lbnQ6djEiLCJpc0NvbmZvcm1hbnRUbyI6InVybjpleGFtcGxlOmV2aWRlbmNlLXR5cGU6djEiLCJpc3N1ZWRCeSI6InVybjpleGFtcGxlOmlzc3VlciIsInByb3ZpZGVkQnkiOiJ1cm46ZXhhbXBsZTpwcm92aWRlciIsImlzc3VlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJvYnNlcnZlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJ2YWxpZFVudGlsIjoiMjAzNi0wNy0yOVQwMDowMDowMFoiLCJwdXJwb3NlIjoiZXhhbXBsZS1wdXJwb3NlIiwiYXVkaWVuY2UiOiJ1cm46ZXhhbXBsZTphdWRpZW5jZSIsImNvbmZpZ3VyYXRpb25SZXZpc2lvbiI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic3ViamVjdHMiOlt7InJvbGUiOiJzdWJqZWN0IiwiYmluZGluZyI6InVybjpldmlkZW5jZTpzdWJqZWN0OnYxX0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEifV0sInN1cHBvcnRlZFZhbHVlcyI6W3sicHJvdmlkZXNWYWx1ZUZvciI6InVybjpleGFtcGxlOmNvbmNlcHQ6c3RhdHVzLWhvbGRzIiwidmFsdWUiOnRydWV9XX0", + "signature": "osWWg3suF31FSjdag6LtBdGs8E9RO47XOhAqbtUhF9ZugYk8yh9WuZkRNcLo_k9A5ONmwy-aT9eA8ept8JmdCw" +} diff --git a/crates/registry-evidence-client-node/tests/golden_fixture.rs b/crates/registry-evidence-client-node/tests/golden_fixture.rs new file mode 100644 index 000000000..883e600f0 --- /dev/null +++ b/crates/registry-evidence-client-node/tests/golden_fixture.rs @@ -0,0 +1,212 @@ +//! Golden fixture for the JS suite's `discover`/`fetchJwks` stubs and for a +//! direct Rust-side check that a stored response still verifies. +//! +//! A JS test cannot sign an Evidence response itself: the crates that can +//! (`registry-evidence-verifier` and `registry-evidence-client`) keep their +//! test signers `#[cfg(test)]`-private, so this file builds one directly with +//! `registry-platform-crypto`, the same way those crates' own tests do. +//! +//! Regenerate with: +//! ```text +//! cargo test -p registry-evidence-client-node --test golden_fixture -- --ignored regenerate_golden_fixture +//! ``` +//! The signing key is generated fresh every run and discarded; only its +//! public half is committed, inside `tests/fixtures/jwks.json`. + +use std::{fs, path::Path}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use ed25519_dalek::SigningKey; +use registry_evidence_client::{ + AssuranceProfile, Evidence, EvidenceObjectType, EvidenceVerificationPolicyDocument, + ExpectedFormDocument, ExpectedOutputDocument, ExpectedScalarFormDocument, + ExpectedSubjectDocument, JwksDocument, PublicValue, SubjectBinding, SupportedValue, +}; +use registry_evidence_verifier::{ + model::FlattenedJws, verifier::verify_flattened_jws, EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, + EVIDENCE_SCHEMA_V1, +}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + +/// Canonical all-zero nonce for offline fixture evaluation, matching the +/// convention `registry-evidence-verifier`'s own fixtures use. A real request +/// always carries a freshly generated nonce; this fixture never goes through +/// `prepare`, so there is nothing independent to match it against. +const FIXTURE_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const ACTIVE_KEY_ID: &str = "evidence-node-fixture-key-1"; + +/// Ten years past `issued_at`. The JS suite runs this fixture through +/// `discover`/`fetchJwks` stubs indefinitely into the future, so its validity +/// window has to outlive ordinary gaps between regenerations, not just the day +/// it was generated. +const FIXTURE_LIFETIME_DAYS: i64 = 3650; + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn fixture_evidence(issued_at: DateTime, valid_until: DateTime) -> Evidence { + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_owned(), + assurance_profile: AssuranceProfile::Local, + request_nonce: FIXTURE_NONCE.to_owned(), + id: "urn:example:evidence:node-fixture".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_owned(), + is_conformant_to: "urn:example:evidence-type:v1".to_owned(), + issued_by: "urn:example:issuer".to_owned(), + provided_by: "urn:example:provider".to_owned(), + issued_at: issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + observed_at: issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + valid_until: valid_until.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + purpose: "example-purpose".to_owned(), + audience: "urn:example:audience".to_owned(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept:status-holds".to_owned(), + value: PublicValue::Boolean(true), + }], + } +} + +fn fixture_policy_document(evidence: &Evidence) -> EvidenceVerificationPolicyDocument { + EvidenceVerificationPolicyDocument { + expected_assurance_profile: evidence.assurance_profile, + issued_by: evidence.issued_by.clone(), + provided_by: evidence.provided_by.clone(), + requirement: evidence.supports_requirement.clone(), + evidence_type: evidence.is_conformant_to.clone(), + purpose: evidence.purpose.clone(), + audience: evidence.audience.clone(), + configuration_revision: evidence.configuration_revision.clone(), + request_nonce: evidence.request_nonce.clone(), + expected_subjects: evidence + .subjects + .iter() + .map(|subject| ExpectedSubjectDocument { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect(), + expected_outputs: evidence + .supported_values + .iter() + .map(|value| ExpectedOutputDocument { + concept: value.provides_value_for.clone(), + form: ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean), + }) + .collect(), + maximum_assertion_lifetime_seconds: (FIXTURE_LIFETIME_DAYS * 24 * 60 * 60) as u64, + clock_skew_seconds: 30, + } +} + +#[derive(serde::Serialize)] +struct ProtectedHeader<'a> { + alg: &'static str, + kid: &'a str, + typ: &'static str, + cty: &'static str, +} + +async fn sign(evidence: &Evidence, signer: &LocalJwkSigner) -> FlattenedJws { + let payload = serde_json::to_vec(evidence).expect("evidence serializes"); + let protected = serde_json::to_vec(&ProtectedHeader { + alg: "EdDSA", + kid: signer.key_id(), + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }) + .expect("protected header serializes"); + + let protected = URL_SAFE_NO_PAD.encode(protected); + let payload = URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{protected}.{payload}"); + let signature = signer + .sign(signing_input.as_bytes()) + .await + .expect("the fixture key signs"); + + FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + } +} + +fn write_pretty(path: &Path, value: &T) { + let mut json = serde_json::to_string_pretty(value).expect("the fixture serializes"); + json.push('\n'); + fs::write(path, json).unwrap_or_else(|error| panic!("writing {path:?} failed: {error}")); +} + +/// Rewrites the three committed fixture files from a freshly generated key, +/// used once and discarded here. Not run by the ordinary suite; see the +/// module doc comment for the exact command. +#[tokio::test] +#[ignore] +async fn regenerate_golden_fixture() { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the host supplies randomness"); + let signing_key = SigningKey::from_bytes(&seed); + let private_jwk_json = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": ACTIVE_KEY_ID, + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + }); + let private_jwk = + PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); + let signer = LocalJwkSigner::new(private_jwk).expect("the generated key signs"); + + let issued_at: DateTime = "2026-08-01T00:00:00Z".parse().expect("issued_at parses"); + let valid_until = issued_at + ChronoDuration::days(FIXTURE_LIFETIME_DAYS); + let evidence = fixture_evidence(issued_at, valid_until); + let policy_document = fixture_policy_document(&evidence); + let jws = sign(&evidence, &signer).await; + let jwks = JwksDocument { + keys: vec![serde_json::to_value(signer.public_jwk()).expect("the public key serializes")], + }; + + let dir = fixtures_dir(); + fs::create_dir_all(dir).expect("the fixtures directory can be created"); + write_pretty(&dir.join("response.jws.json"), &jws); + write_pretty(&dir.join("jwks.json"), &jwks); + write_pretty(&dir.join("policy.json"), &policy_document); +} + +/// Confirms the committed fixture still verifies against the real wall clock, +/// so the JS suite can trust `jwks.json` and `response.jws.json` without +/// re-deriving them. +#[test] +fn golden_fixture_verifies_against_the_real_clock() { + let dir = fixtures_dir(); + let jws_bytes = fs::read(dir.join("response.jws.json")).expect("the response fixture exists"); + let jwks: JwksDocument = + serde_json::from_slice(&fs::read(dir.join("jwks.json")).expect("the JWKS fixture exists")) + .expect("the JWKS fixture parses"); + let policy_document: EvidenceVerificationPolicyDocument = serde_json::from_slice( + &fs::read(dir.join("policy.json")).expect("the policy fixture exists"), + ) + .expect("the policy fixture parses"); + + let policy = policy_document.into_policy(Utc::now()); + let evidence = verify_flattened_jws(&jws_bytes, &jwks, &policy).expect("the fixture verifies"); + + assert_eq!(evidence.request_nonce, FIXTURE_NONCE); + assert_eq!(evidence.subjects.len(), 1); + assert_eq!(evidence.subjects[0].role, "subject"); + assert_eq!(evidence.supported_values.len(), 1); + assert!(matches!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + )); +} diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index 7d35ed846..89ae4e177 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -36,7 +36,9 @@ source is covered by the same source-product and domain neutrality checks. links `registry-evidence-verifier` for every verification decision, so it re-implements no part of evaluation, signing, or verification. It sits outside the frozen Version 1 runtime contract, and its source is covered by the same -source-product and domain neutrality checks. +source-product and domain neutrality checks. `registry-evidence-client-node` is +a thin napi-rs binding over `registry-evidence-client` for Node.js callers, and +carries the same neutrality checks. Selected `registry-platform-*` primitives may be reused only when their existing contracts fit Evidence directly. The approved candidates are audit, crypto, diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index ba67c73ae..3df7f670c 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -11,6 +11,7 @@ for source_file in $( rg --files \ "$repository_root/crates/registry-evidence/src" \ "$repository_root/crates/registry-evidence-client/src" \ + "$repository_root/crates/registry-evidence-client-node/src" \ "$repository_root/crates/registry-evidence-verifier/src" \ "$repository_root/crates/registry-evidencectl/src" \ -g '*.rs' | sort @@ -136,6 +137,7 @@ if rg -n -i 'dhis2|opencrvs' \ "$production_text" \ "$repository_root/crates/registry-evidence/Cargo.toml" \ "$repository_root/crates/registry-evidence-client/Cargo.toml" \ + "$repository_root/crates/registry-evidence-client-node/Cargo.toml" \ "$repository_root/crates/registry-evidence-verifier/Cargo.toml" \ "$repository_root/crates/registry-evidencectl/Cargo.toml" \ "$repository_root/Cargo.toml"; then From 9ad48f3031260adae9c55f2e45c3dbb08d81b343 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 14:51:11 +0700 Subject: [PATCH 26/67] feat(evidence): surface TokenError's own kind on mapped errors TokenError::kind() described which token failure occurred but never reached the JSON envelope map_client_error hands to callers. Insert it as tokenKind alongside the existing sub-fields, so a token failure's own discriminant is inspectable the same way transportKind and code already are. Also add a redaction test for this mapping layer, covering a credential, a malformed signing key, a selector value, and a pinned subject binding: each is planted as a canary in an otherwise-refused input, and the mapped envelope must never repeat it. Signed-off-by: Jeremi Joslin --- .../src/convert.rs | 114 +++++++++++++++++- 1 file changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index d86d31b34..0887588ba 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -451,11 +451,15 @@ pub fn map_config_error(error: &ConfigError) -> Value { /// Map any [`EvidenceClientError`] to the stable JSON envelope described in /// the crate's `AGENTS.md`-linked design: `kind` and `message` always, plus -/// whichever of `status`, `code`, `operation`, `retryAfterSeconds`, and -/// `transportKind` the variant carries. `code` is deliberately overloaded: a -/// `Denied`/`Protocol` wire code, a `Token::Refused` OAuth code, and a -/// `Verification` failure's own kind string all travel in the same member, -/// since a caller branches on `kind` first and `code` only refines it. +/// whichever of `status`, `code`, `operation`, `retryAfterSeconds`, +/// `transportKind`, and `tokenKind` the variant carries. `code` is +/// deliberately overloaded: a `Denied`/`Protocol` wire code, a +/// `Token::Refused` OAuth code, and a `Verification` failure's own kind string +/// all travel in the same member, since a caller branches on `kind` first and +/// `code` only refines it. `tokenKind` is `Token`'s own analogous second +/// discriminant: every `TokenError` carries one, from [`TokenError::kind`], +/// alongside whichever further sub-fields (`transportKind`, `code`, `status`) +/// that specific variant also carries. /// /// Never included: response bytes, a credential, a header value, a selector /// value, or a subject binding. Every message here is `Display` text over @@ -516,6 +520,10 @@ pub fn map_client_error(error: &EvidenceClientError) -> Value { } fn insert_token_fields(fields: &mut Map, error: &TokenError) { + fields.insert( + "tokenKind".to_owned(), + Value::String(error.kind().to_owned()), + ); match error { TokenError::Unavailable | TokenError::Invalid { .. } | TokenError::Configuration { .. } => { } @@ -1029,24 +1037,43 @@ mod tests { fn a_token_failure_nests_its_own_sub_kind_details_under_the_token_kind() { let unavailable = map_client_error(&EvidenceClientError::Token(TokenError::Unavailable)); assert_eq!(unavailable["kind"], "token"); - assert_eq!(unavailable.as_object().unwrap().len(), 2); + assert_eq!(unavailable["tokenKind"], "unavailable"); + assert_eq!(unavailable.as_object().unwrap().len(), 3); + + let invalid = map_client_error(&EvidenceClientError::Token(TokenError::Invalid { + reason: "a bearer credential must be non-empty and within the accepted length", + })); + assert_eq!(invalid["kind"], "token"); + assert_eq!(invalid["tokenKind"], "invalid_credential"); + assert_eq!(invalid.as_object().unwrap().len(), 3); + + let configuration = + map_client_error(&EvidenceClientError::Token(TokenError::Configuration { + reason: "the token provider cannot be used this way", + })); + assert_eq!(configuration["kind"], "token"); + assert_eq!(configuration["tokenKind"], "configuration"); + assert_eq!(configuration.as_object().unwrap().len(), 3); let transport = map_client_error(&EvidenceClientError::Token(TokenError::Transport { kind: TransportKind::Connect, })); assert_eq!(transport["kind"], "token"); + assert_eq!(transport["tokenKind"], "transport"); assert_eq!(transport["transportKind"], "connect"); let refused = map_client_error(&EvidenceClientError::Token(TokenError::Refused { code: OAuthErrorCode::InvalidClient, })); assert_eq!(refused["kind"], "token"); + assert_eq!(refused["tokenKind"], "refused"); assert_eq!(refused["code"], "invalid_client"); let protocol = map_client_error(&EvidenceClientError::Token(TokenError::Protocol { status: 500, })); assert_eq!(protocol["kind"], "token"); + assert_eq!(protocol["tokenKind"], "protocol"); assert_eq!(protocol["status"], 500); } @@ -1089,4 +1116,79 @@ mod tests { assert_eq!(mapped["kind"], "configuration"); assert_eq!(mapped["message"], "bad shape"); } + + // --- redaction --- + + /// Mirrors the wrapped crate's own redaction tests + /// (`debug_output_never_carries_the_credential` in `token.rs`, + /// `debug_output_never_carries_a_response_body_or_a_credential` in + /// `client.rs`): plant a canary value in every place a credential, key, + /// selector value, or subject binding legitimately reaches this crate's + /// own conversion and error-mapping layer, and confirm the mapped JSON + /// envelope this crate hands to JS never repeats it. A response body + /// never reaches this module at all (`map_client_error` never touches + /// `RawEvidenceResponse`), so it has no case here; the wrapped crate's own + /// test already covers it. + #[test] + fn mapped_errors_never_carry_a_credential_key_selector_value_or_subject_binding() { + const CANARY: &str = "secret-canary-value"; + + // A bearer credential shaped exactly as a caller might submit one by + // mistake (here, carrying a trailing newline `BearerToken` refuses): + // the fixed refusal reason must not repeat the credential itself. + let token_error = + StaticToken::new(format!("{CANARY}\n")).expect_err("a newline is refused"); + let mapped = map_client_error(&EvidenceClientError::Token(token_error)); + let rendered = serde_json::to_string(&mapped).expect("the envelope serializes"); + assert!(!rendered.contains(CANARY), "leaked in: {rendered}"); + + // A signing key whose private component is the canary: well-shaped + // JSON, but not a valid Ed25519 scalar, so `PrivateJwk::parse` refuses + // it. The refusal must describe the field (`d`), never echo it. + let config_json = serde_json::json!({ + "baseUrl": "https://evidence.example.org", + "trustedJwks": one_key_jwks_json(), + "token": { + "privateKeyJwt": { + "tokenEndpoint": "https://issuer.example.org/token", + "clientId": "example-client", + "clientKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "d": CANARY, + }, + }, + }, + }); + let error = config_from_json(&config_json).expect_err("the malformed key is refused"); + let mapped = map_config_error(&error); + let rendered = serde_json::to_string(&mapped).expect("the envelope serializes"); + assert!(!rendered.contains(CANARY), "leaked in: {rendered}"); + + // A selector value carrying the canary, in a specification refused + // for an unrelated reason (a missing `purpose`): the canary is parsed + // and held in memory before the refusal, but the refusal itself must + // not mention it. + let mut spec = valid_spec_json(); + spec["subjects"][0]["selectorValues"]["record_reference"] = + Value::String(CANARY.to_owned()); + spec.as_object_mut().unwrap().remove("purpose"); + let error = spec_from_json(&spec).expect_err("the missing `purpose` is refused"); + let mapped = map_conversion_error(&error); + let rendered = serde_json::to_string(&mapped).expect("the envelope serializes"); + assert!(!rendered.contains(CANARY), "leaked in: {rendered}"); + + // A pinned subject binding carrying the canary, in a specification + // refused for the same unrelated reason. + let mut spec = valid_spec_json(); + spec["subjectExpectations"] = serde_json::json!({ + "pinned": [{ "role": "subject", "binding": CANARY }], + }); + spec.as_object_mut().unwrap().remove("purpose"); + let error = spec_from_json(&spec).expect_err("the missing `purpose` is refused"); + let mapped = map_conversion_error(&error); + let rendered = serde_json::to_string(&mapped).expect("the envelope serializes"); + assert!(!rendered.contains(CANARY), "leaked in: {rendered}"); + } } From d0acad5fc97c2d9eb1006a88c77917fac225f8cc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 14:51:18 +0700 Subject: [PATCH 27/67] fix(evidence): catch panics at the Node binding's sync entry points napi-derive 3.6.2's generated glue carries no panic handling for synchronous #[napi] functions; only napi's own tokio bridge wraps asynchronous work in catch_unwind. A panic in the constructor, prepare, verify, verifyAsOf, or a PreparedEvidenceRequest/ RawEvidenceResponse getter would otherwise unwind across the FFI boundary and abort the whole Node process instead of rejecting one call. Add a catch_panic helper and route every synchronous entry point through it. The reported reason is fixed and never echoes the panic payload, since a panic is an unvalidated code path that carries none of the redaction guarantees the rest of this crate's error reporting is held to. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client-node/src/lib.rs | 171 ++++++++++++++---- 1 file changed, 133 insertions(+), 38 deletions(-) diff --git a/crates/registry-evidence-client-node/src/lib.rs b/crates/registry-evidence-client-node/src/lib.rs index 71e6ee10d..2728532ca 100644 --- a/crates/registry-evidence-client-node/src/lib.rs +++ b/crates/registry-evidence-client-node/src/lib.rs @@ -70,6 +70,38 @@ fn to_napi_serialization_error(what: &str, error: serde_json::Error) -> NapiErro NapiError::from_reason(format!("{what} could not be described: {error}")) } +/// Run a synchronous `#[napi]` entry point, turning a caught panic into an +/// ordinary rejection instead of letting the unwind cross the FFI boundary, +/// which aborts the whole process. +/// +/// napi-derive 3.6.2's generated glue carries no panic handling of its own for +/// synchronous `#[napi]` functions; only `napi`'s tokio bridge wraps +/// asynchronous work in `catch_unwind`. Every synchronous entry point in this +/// file (the constructor, `prepare`, `verify`, `verify_as_of`, and the +/// `PreparedEvidenceRequest`/`RawEvidenceResponse` getters) routes through +/// this helper. +/// +/// `AssertUnwindSafe` is appropriate here: every call site immediately +/// converts a caught panic into a returned `Err` and never inspects or +/// continues using whatever state the unwinding closure touched, so a +/// theoretically inconsistent intermediate state cannot leak into further +/// observable behavior. +/// +/// The reported reason is fixed and never echoes the panic payload: a panic +/// is, by construction, a code path nobody validated ahead of time, so its +/// payload carries none of the redaction guarantees the rest of this crate's +/// error reporting is held to. +fn catch_panic(what: &'static str, f: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) + .unwrap_or_else(|_payload| Err(to_napi_panic_error(what))) +} + +fn to_napi_panic_error(what: &'static str) -> NapiError { + NapiError::from_reason(format!( + "{what} failed unexpectedly and could not complete; this is a defect, not a refusal" + )) +} + /// One request, closed and nonce-bearing, before any byte has left the /// process. /// @@ -85,23 +117,31 @@ pub struct PreparedEvidenceRequest { impl PreparedEvidenceRequest { /// The nonce this request carries. Retain it with the transaction record. #[napi(getter)] - pub fn request_nonce(&self) -> String { - self.inner.request_nonce().to_owned() + pub fn request_nonce(&self) -> Result { + catch_panic("reading the request nonce", || { + Ok(self.inner.request_nonce().to_owned()) + }) } /// The closed verification policy, with the subject set as `prepare` left /// it. #[napi(getter)] pub fn policy_document(&self) -> Result { - serde_json::to_value(self.inner.policy_document()) - .map_err(|error| to_napi_serialization_error("the policy document", error)) + catch_panic("reading the policy document", || { + serde_json::to_value(self.inner.policy_document()) + .map_err(|error| to_napi_serialization_error("the policy document", error)) + }) } /// `"acceptFirstUse"` or `{ pinned: [{ role, binding }, ...] }`, exactly as /// this request was prepared. #[napi(getter)] - pub fn subject_expectations(&self) -> serde_json::Value { - subject_expectations_to_json(self.inner.subject_expectations()) + pub fn subject_expectations(&self) -> Result { + catch_panic("reading the subject expectations", || { + Ok(subject_expectations_to_json( + self.inner.subject_expectations(), + )) + }) } } @@ -119,15 +159,19 @@ impl RawEvidenceResponse { /// The signed response bytes, exactly as received. Nothing in them has /// been trusted yet; `verify` is what judges them. #[napi(getter)] - pub fn body(&self) -> Buffer { - self.inner.body().to_vec().into() + pub fn body(&self) -> Result { + catch_panic("reading the response body", || { + Ok(self.inner.body().to_vec().into()) + }) } /// The deployment's opaque identifier for this exchange, for support /// correlation. #[napi(getter)] - pub fn operation(&self) -> Option { - self.inner.operation().map(str::to_owned) + pub fn operation(&self) -> Result> { + catch_panic("reading the response operation", || { + Ok(self.inner.operation().map(str::to_owned)) + }) } } @@ -174,12 +218,14 @@ impl EvidenceClient { /// key set is refused, exactly as the Rust configuration is. #[napi(constructor)] pub fn new(config: serde_json::Value) -> Result { - let config = - config_from_json(&config).map_err(|error| to_napi_error(map_config_error(&error)))?; - let client = RealEvidenceClient::new(config) - .map_err(|error| to_napi_error(map_client_error(&error)))?; - Ok(Self { - inner: Arc::new(client), + catch_panic("constructing the client", || { + let config = config_from_json(&config) + .map_err(|error| to_napi_error(map_config_error(&error)))?; + let client = RealEvidenceClient::new(config) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + Ok(Self { + inner: Arc::new(client), + }) }) } @@ -188,14 +234,16 @@ impl EvidenceClient { /// spend it with `send` or `requestAndVerify`. #[napi] pub fn prepare(&self, spec: serde_json::Value) -> Result { - let spec = - spec_from_json(&spec).map_err(|error| to_napi_error(map_conversion_error(&error)))?; - let prepared = self - .inner - .prepare(spec) - .map_err(|error| to_napi_error(map_client_error(&error)))?; - Ok(PreparedEvidenceRequest { - inner: Arc::new(prepared), + catch_panic("preparing a request", || { + let spec = spec_from_json(&spec) + .map_err(|error| to_napi_error(map_conversion_error(&error)))?; + let prepared = self + .inner + .prepare(spec) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + Ok(PreparedEvidenceRequest { + inner: Arc::new(prepared), + }) }) } @@ -266,11 +314,13 @@ impl EvidenceClient { prepared: &PreparedEvidenceRequest, response: &RawEvidenceResponse, ) -> Result { - let verified = self - .inner - .verify(&prepared.inner, &response.inner) - .map_err(|error| to_napi_error(map_client_error(&error)))?; - verified_evidence_to_napi(&verified) + catch_panic("verifying a response", || { + let verified = self + .inner + .verify(&prepared.inner, &response.inner) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + verified_evidence_to_napi(&verified) + }) } /// Request evidence and verify it in one step. This spends the single @@ -313,14 +363,59 @@ impl EvidenceClient { response: &RawEvidenceResponse, as_of_millis: f64, ) -> Result { - let millis = as_of_millis as i64; - let now = chrono::DateTime::from_timestamp_millis(millis).ok_or_else(|| { - NapiError::from_reason("`asOfMillis` is not a representable instant".to_owned()) - })?; - let verified = self - .inner - .verify_as_of(&prepared.inner, &response.inner, now) - .map_err(|error| to_napi_error(map_client_error(&error)))?; - verified_evidence_to_napi(&verified) + catch_panic("verifying a response as of an instant", || { + let millis = as_of_millis as i64; + let now = chrono::DateTime::from_timestamp_millis(millis).ok_or_else(|| { + NapiError::from_reason("`asOfMillis` is not a representable instant".to_owned()) + })?; + let verified = self + .inner + .verify_as_of(&prepared.inner, &response.inner, now) + .map_err(|error| to_napi_error(map_client_error(&error)))?; + verified_evidence_to_napi(&verified) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_ordinary_result_passes_through_catch_panic_unchanged() { + let ok: Result = catch_panic("a synthetic operation", || Ok(42)); + assert_eq!(ok.unwrap(), 42); + + let err: Result = catch_panic("a synthetic operation", || { + Err(NapiError::from_reason("an ordinary refusal".to_owned())) + }); + assert_eq!(err.unwrap_err().reason, "an ordinary refusal"); + } + + /// Proves the one hazard `catch_panic` exists to close: without it, this + /// panic would unwind straight across the `#[napi]` boundary and abort + /// the process rather than reject one call. + /// + /// The default panic hook is silenced for the duration of this test so + /// the synthetic panic below does not print to stderr; no other test in + /// this crate panics, so swapping the process-wide hook here does not + /// affect any other test's output. + #[test] + fn a_caught_panic_becomes_an_ordinary_error_rather_than_an_abort() { + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let result: Result<()> = catch_panic("a synthetic operation", || { + panic!("a synthetic panic carrying a canary-value that must not surface") + }); + std::panic::set_hook(previous_hook); + + let error = result.expect_err("the panic is caught and reported as an error"); + assert!(error.reason.contains("a synthetic operation")); + assert!(error.reason.contains("failed unexpectedly")); + assert!( + !error.reason.contains("canary-value"), + "the panic payload leaked into the reported reason: {}", + error.reason + ); } } From 1c07fd29f42ff42b9758ad43d9832a4058019da1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 14:51:35 +0700 Subject: [PATCH 28/67] feat(evidence): give Node callers structured error properties error.message from the generated binding was a JSON-stringified envelope, so a caller had to JSON.parse it to branch on kind, status, code, operation, retryAfterSeconds, transportKind, or tokenKind. That defeats both instanceof and ordinary property access. Add client.js: a hand-written entry point that patches the native EvidenceClient.prototype methods and the PreparedEvidenceRequest/ RawEvidenceResponse prototype getters in place, normalizing any mapped failure into an EvidenceClientError with the discriminants as real properties and a plain-prose message. Patching the prototype (rather than wrapping objects) keeps native object identity intact, which the single-send guard on PreparedEvidenceRequest depends on; a test now proves both the identity and the guard still hold. EvidenceClient itself is subclassed only to intercept the constructor's own throw, which is safe because nothing checks instanceof native.EvidenceClient on an EvidenceClient argument anywhere in the native layer. index.d.ts stays generated and byte-compared by check:types, so the hand-written EvidenceClientError declaration lives in its own client.d.ts, which re-exports index.d.ts wholesale and becomes the package's types entry. A new drift test introspects the built native module and cross-checks it against client.js/client.d.ts, so the hand-written surface cannot silently fall out of step with the native one it wraps. Update the existing tests to assert on error properties directly instead of parsing error.message. Signed-off-by: Jeremi Joslin --- .../__test__/construction.test.js | 11 +- .../__test__/discovery.test.js | 2 +- .../__test__/drift.test.js | 96 +++++++++++ .../__test__/errors.test.js | 11 +- .../__test__/happy-path.test.js | 19 ++- .../registry-evidence-client-node/client.d.ts | 32 ++++ .../registry-evidence-client-node/client.js | 153 ++++++++++++++++++ .../package.json | 6 +- 8 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 crates/registry-evidence-client-node/__test__/drift.test.js create mode 100644 crates/registry-evidence-client-node/client.d.ts create mode 100644 crates/registry-evidence-client-node/client.js diff --git a/crates/registry-evidence-client-node/__test__/construction.test.js b/crates/registry-evidence-client-node/__test__/construction.test.js index 6edbf6981..c9d38cd5b 100644 --- a/crates/registry-evidence-client-node/__test__/construction.test.js +++ b/crates/registry-evidence-client-node/__test__/construction.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); -const { EvidenceClient } = require('../index.js'); +const { EvidenceClient, EvidenceClientError } = require('..'); function validConfig(overrides = {}) { return { @@ -24,12 +24,13 @@ function validConfig(overrides = {}) { } /** Every construction refusal in this file is the Rust configuration - * failure, crossing as a thrown error whose `message` is the stable JSON - * envelope `kind: "configuration"`. */ + * failure, crossing as an `EvidenceClientError` with `kind: "configuration"` + * and a human-readable `message` (not JSON a caller must parse). */ function assertConfigurationRefusal(build) { assert.throws(build, (error) => { - const mapped = JSON.parse(error.message); - assert.equal(mapped.kind, 'configuration'); + assert.ok(error instanceof EvidenceClientError); + assert.equal(error.kind, 'configuration'); + assert.doesNotMatch(error.message, /^\{/, 'message must be prose, not a JSON envelope'); return true; }); } diff --git a/crates/registry-evidence-client-node/__test__/discovery.test.js b/crates/registry-evidence-client-node/__test__/discovery.test.js index 48d199d2f..e1d832095 100644 --- a/crates/registry-evidence-client-node/__test__/discovery.test.js +++ b/crates/registry-evidence-client-node/__test__/discovery.test.js @@ -5,7 +5,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { test } = require('node:test'); -const { EvidenceClient } = require('../index.js'); +const { EvidenceClient } = require('..'); const { startStubServer } = require('./helpers/stub-server'); // The golden fixture's key set is public verification material only (no diff --git a/crates/registry-evidence-client-node/__test__/drift.test.js b/crates/registry-evidence-client-node/__test__/drift.test.js new file mode 100644 index 000000000..ffabf173c --- /dev/null +++ b/crates/registry-evidence-client-node/__test__/drift.test.js @@ -0,0 +1,96 @@ +'use strict'; + +// `client.js`/`client.d.ts` are hand-written, unlike every other file in this +// package: they are not regenerated from `src/lib.rs` the way `index.js`/ +// `index.d.ts` are, so nothing forces them to stay honest about the native +// module's actual surface when that surface changes. This is the Node analog +// of the stub-drift check the Python binding carries for the same reason +// (introspect the built module and assert the hand-written stub names +// exactly what exists, and nothing more). + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const wrapper = require('..'); +const native = require('../index.js'); + +// Every synchronous native `EvidenceClient` method `client.js` must patch +// through `wrapSync`, and every asynchronous (Promise-returning) one through +// `wrapAsync`, so a mapped failure normalizes to an `EvidenceClientError` +// instead of throwing the raw JSON-envelope message. If the native surface +// grows, this list (and `client.js`'s own wrapping calls) must grow with it. +const SYNC_METHODS = ['prepare', 'verify', 'verifyAsOf']; +const ASYNC_METHODS = ['discover', 'fetchJwks', 'send', 'requestAndVerify']; + +function ownMethodNames(prototype) { + return Object.getOwnPropertyNames(prototype) + .filter((name) => name !== 'constructor') + .filter((name) => typeof Object.getOwnPropertyDescriptor(prototype, name).value === 'function'); +} + +function ownGetterNames(prototype) { + return Object.getOwnPropertyNames(prototype).filter( + (name) => typeof Object.getOwnPropertyDescriptor(prototype, name).get === 'function', + ); +} + +test('every native EvidenceClient method is accounted for as sync or async', () => { + const actual = ownMethodNames(native.EvidenceClient.prototype).sort(); + const expected = [...SYNC_METHODS, ...ASYNC_METHODS].sort(); + assert.deepEqual( + actual, + expected, + 'the native EvidenceClient surface changed; update SYNC_METHODS/ASYNC_METHODS and the ' + + 'matching wrapSync/wrapAsync calls in client.js', + ); +}); + +test('every native PreparedEvidenceRequest and RawEvidenceResponse getter is wrapped', () => { + assert.deepEqual(ownGetterNames(native.PreparedEvidenceRequest.prototype).sort(), [ + 'policyDocument', + 'requestNonce', + 'subjectExpectations', + ]); + assert.deepEqual(ownGetterNames(native.RawEvidenceResponse.prototype).sort(), ['body', 'operation']); +}); + +test('every class client.js exports is declared in client.d.ts or the index.d.ts it re-exports', () => { + // `client.d.ts` re-exports `index.d.ts` wholesale (`export * from './index'`) + // rather than repeating each declaration, so a name may legitimately live + // in either file. + const clientDeclaration = fs.readFileSync(path.join(__dirname, '..', 'client.d.ts'), 'utf8'); + const indexDeclaration = fs.readFileSync(path.join(__dirname, '..', 'index.d.ts'), 'utf8'); + for (const name of Object.keys(wrapper)) { + const pattern = new RegExp(`\\b${name}\\b`); + assert.ok( + pattern.test(clientDeclaration) || pattern.test(indexDeclaration), + `neither client.d.ts nor index.d.ts mentions '${name}', which client.js exports`, + ); + } +}); + +test('client.d.ts declares no EvidenceClientError field client.js never sets', () => { + // The exact field list `normalize` copies onto a new `EvidenceClientError`, + // plus `kind`, which the constructor always sets directly. + const settableFields = [ + 'kind', + 'status', + 'code', + 'operation', + 'retryAfterSeconds', + 'transportKind', + 'tokenKind', + ]; + const declaration = fs.readFileSync(path.join(__dirname, '..', 'client.d.ts'), 'utf8'); + const classBody = declaration.slice(declaration.indexOf('class EvidenceClientError')); + const declaredFields = [...classBody.matchAll(/readonly (\w+)[?:]/g)].map((match) => match[1]); + assert.ok(declaredFields.length > 0, 'no fields were found; client.d.ts may have been reshaped'); + for (const field of declaredFields) { + assert.ok( + settableFields.includes(field), + `client.d.ts declares '${field}' but client.js's EvidenceClientError never sets it`, + ); + } +}); diff --git a/crates/registry-evidence-client-node/__test__/errors.test.js b/crates/registry-evidence-client-node/__test__/errors.test.js index e4cb90f57..0cfb2159d 100644 --- a/crates/registry-evidence-client-node/__test__/errors.test.js +++ b/crates/registry-evidence-client-node/__test__/errors.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); -const { EvidenceClient } = require('../index.js'); +const { EvidenceClient, EvidenceClientError } = require('..'); const { startStubServer } = require('./helpers/stub-server'); const { requestSpec } = require('./helpers/live-signing'); @@ -44,7 +44,8 @@ async function assertMappedFailure(stub, assertMapping) { try { const { client, prepared } = await clientAndPrepared(stub); await assert.rejects(client.send(prepared), (error) => { - assertMapping(JSON.parse(error.message)); + assert.ok(error instanceof EvidenceClientError); + assertMapping(error); return true; }); } finally { @@ -174,9 +175,9 @@ test('a response over maxResponseBytes is refused as a transport failure, not a }); const prepared = client.prepare(requestSpec()); await assert.rejects(client.send(prepared), (error) => { - const mapped = JSON.parse(error.message); - assert.equal(mapped.kind, 'transport'); - assert.equal(mapped.transportKind, 'response_too_large'); + assert.ok(error instanceof EvidenceClientError); + assert.equal(error.kind, 'transport'); + assert.equal(error.transportKind, 'response_too_large'); return true; }); } finally { diff --git a/crates/registry-evidence-client-node/__test__/happy-path.test.js b/crates/registry-evidence-client-node/__test__/happy-path.test.js index 9d3618485..caefdfb58 100644 --- a/crates/registry-evidence-client-node/__test__/happy-path.test.js +++ b/crates/registry-evidence-client-node/__test__/happy-path.test.js @@ -3,7 +3,11 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); -const { EvidenceClient } = require('../index.js'); +const { EvidenceClient, EvidenceClientError, PreparedEvidenceRequest } = require('..'); +// The raw native module, used only to prove the package's exported +// `PreparedEvidenceRequest` is the very same native class (see the identity +// assertion below), not a wrapper or a copy of it. +const native = require('../index.js'); const { startStubServer } = require('./helpers/stub-server'); const { generateSigningKey, @@ -92,12 +96,21 @@ test('a second send on the same prepared request is refused locally, and the stu }); const prepared = client.prepare(spec); + // The wrapper module patches `EvidenceClient.prototype` methods in place + // rather than wrapping arguments or return values, so `prepare()` must + // still hand back the exact native object: the single-send guard below + // depends on `send` recognizing the very same `PreparedEvidenceRequest` + // on its second call, not a copy or a proxy around it. + assert.ok(prepared instanceof PreparedEvidenceRequest); + assert.ok(prepared instanceof native.PreparedEvidenceRequest); + assert.equal(PreparedEvidenceRequest, native.PreparedEvidenceRequest); + await client.send(prepared); assert.equal(stub.requests.length, 1); await assert.rejects(client.send(prepared), (error) => { - const mapped = JSON.parse(error.message); - assert.equal(mapped.kind, 'configuration'); + assert.ok(error instanceof EvidenceClientError); + assert.equal(error.kind, 'configuration'); return true; }); assert.equal(stub.requests.length, 1, 'the stub must not see a second request'); diff --git a/crates/registry-evidence-client-node/client.d.ts b/crates/registry-evidence-client-node/client.d.ts new file mode 100644 index 000000000..7c3c05973 --- /dev/null +++ b/crates/registry-evidence-client-node/client.d.ts @@ -0,0 +1,32 @@ +// The package's actual entry point. `index.d.ts` is auto-generated by +// `napi build --platform` on every build and is what `check:types` diffs +// byte-for-byte; the hand-written surface below is checked separately, by +// `__test__/drift.test.js`, against the real module. +export * from './index' + +/** + * The stable eight-kind error envelope every mapped Evidence Node failure + * carries, as properties on a thrown `Error` (see `src/lib.rs`'s + * `to_napi_error` and `src/convert.rs`'s `map_client_error` for the Rust side + * of this contract). + * + * `kind` is always present. The rest are present only when the underlying + * failure carries them: + * - `status`: `denied` and `protocol` + * - `code`: `denied`, `protocol` (optional), `verification`, and any `token` + * failure whose `tokenKind` is `refused` + * - `operation`: `denied`, `not_available`, `protocol` (all optional) + * - `retryAfterSeconds`: `denied`, `protocol` (both optional) + * - `transportKind`: `transport`, and any `token` failure whose `tokenKind` + * is `transport` + * - `tokenKind`: every `token` failure + */ +export declare class EvidenceClientError extends Error { + readonly kind: string + readonly status?: number + readonly code?: string + readonly operation?: string + readonly retryAfterSeconds?: number + readonly transportKind?: string + readonly tokenKind?: string +} diff --git a/crates/registry-evidence-client-node/client.js b/crates/registry-evidence-client-node/client.js new file mode 100644 index 000000000..61ecdb073 --- /dev/null +++ b/crates/registry-evidence-client-node/client.js @@ -0,0 +1,153 @@ +'use strict'; + +// The package's actual entry point. `index.js`/`index.d.ts` are regenerated +// by `napi build --platform` on every build (see `package.json`'s `build` +// and `build:debug` scripts) and stay untouched here; this file sits on top +// of them so a hand edit never gets silently overwritten by the next build. + +const native = require('./index'); + +/** + * The stable eight-kind error envelope every mapped Evidence Node failure + * carries (see `crates/registry-evidence-client-node/src/convert.rs`'s + * `map_client_error`), as properties on a thrown `Error` rather than as JSON + * text a caller has to `JSON.parse` out of `.message`. + * + * `kind` is always present; `status`, `code`, `operation`, + * `retryAfterSeconds`, `transportKind`, and `tokenKind` are present only when + * the underlying failure carries them. + */ +class EvidenceClientError extends Error { + constructor(envelope) { + super(envelope.message); + this.name = 'EvidenceClientError'; + this.kind = envelope.kind; + for (const field of [ + 'status', + 'code', + 'operation', + 'retryAfterSeconds', + 'transportKind', + 'tokenKind', + ]) { + if (envelope[field] !== undefined) { + this[field] = envelope[field]; + } + } + } +} + +/** + * The native layer throws every mapped Evidence failure as an ordinary + * `Error` whose `message` is the JSON envelope described above + * (`src/lib.rs`'s `to_napi_error`). Every other native failure (a + * serialization defect, a caught panic, napi's own argument-type checking) + * throws a plain, non-JSON reason instead, and is left exactly as thrown: + * only a recognized envelope becomes an `EvidenceClientError`, so a caller + * cannot mistake "some other native failure" for one of the eight stable + * kinds. + */ +function normalize(error) { + if (!(error instanceof Error) || typeof error.message !== 'string') { + return error; + } + let envelope; + try { + envelope = JSON.parse(error.message); + } catch { + return error; + } + if (envelope === null || typeof envelope !== 'object' || typeof envelope.kind !== 'string') { + return error; + } + return new EvidenceClientError(envelope); +} + +function wrapSync(prototype, name) { + const original = prototype[name]; + prototype[name] = function (...args) { + try { + return original.apply(this, args); + } catch (error) { + throw normalize(error); + } + }; +} + +function wrapAsync(prototype, name) { + const original = prototype[name]; + prototype[name] = function (...args) { + return original.apply(this, args).catch((error) => { + throw normalize(error); + }); + }; +} + +function wrapGetter(prototype, name) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + const originalGet = descriptor.get; + Object.defineProperty(prototype, name, { + ...descriptor, + get() { + try { + return originalGet.call(this); + } catch (error) { + throw normalize(error); + } + }, + }); +} + +// Patch the native prototypes directly, in place, rather than wrapping +// instances in a delegating object or a `Proxy`: only the throw/reject path +// is touched here, so every argument and return value crossing these methods +// keeps its exact native identity. This matters concretely for `send`'s +// single-send guard: it depends on the `PreparedEvidenceRequest` object +// handed back to a later `send`/`verify` call being the very same native +// object `prepare` returned, not a copy or a wrapper around it (see +// `__test__/happy-path.test.js`'s single-send-guard test, which asserts this +// identity directly). +wrapSync(native.EvidenceClient.prototype, 'prepare'); +wrapAsync(native.EvidenceClient.prototype, 'discover'); +wrapAsync(native.EvidenceClient.prototype, 'fetchJwks'); +wrapAsync(native.EvidenceClient.prototype, 'send'); +wrapSync(native.EvidenceClient.prototype, 'verify'); +wrapAsync(native.EvidenceClient.prototype, 'requestAndVerify'); +wrapSync(native.EvidenceClient.prototype, 'verifyAsOf'); + +wrapGetter(native.PreparedEvidenceRequest.prototype, 'requestNonce'); +wrapGetter(native.PreparedEvidenceRequest.prototype, 'policyDocument'); +wrapGetter(native.PreparedEvidenceRequest.prototype, 'subjectExpectations'); + +wrapGetter(native.RawEvidenceResponse.prototype, 'body'); +wrapGetter(native.RawEvidenceResponse.prototype, 'operation'); + +/** + * The one class this module wraps rather than patches in place: a + * constructor's own throw happens before any instance exists, so there is no + * prototype method to patch ahead of time the way there is for every other + * method above. + * + * Subclassing is safe here in a way it would not be for + * `PreparedEvidenceRequest`: nothing hands an `EvidenceClient` back into a + * later native call for an identity check to depend on, and every prototype + * method above is already patched on `native.EvidenceClient.prototype`, which + * this subclass inherits unchanged, so `instanceof native.EvidenceClient` + * still holds for its instances. + */ +class EvidenceClient extends native.EvidenceClient { + constructor(config) { + try { + super(config); + } catch (error) { + throw normalize(error); + } + } +} + +module.exports = { + EvidenceClient, + EvidenceClientError, + PreparedEvidenceRequest: native.PreparedEvidenceRequest, + RawEvidenceResponse: native.RawEvidenceResponse, +}; diff --git a/crates/registry-evidence-client-node/package.json b/crates/registry-evidence-client-node/package.json index 6a62e9c1a..578b2a0f4 100644 --- a/crates/registry-evidence-client-node/package.json +++ b/crates/registry-evidence-client-node/package.json @@ -7,9 +7,11 @@ "type": "git", "url": "https://github.com/registrystack/registry-stack" }, - "main": "index.js", - "types": "index.d.ts", + "main": "client.js", + "types": "client.d.ts", "files": [ + "client.js", + "client.d.ts", "index.js", "index.d.ts", "*.node" From 7c6c2b1ed3f471ee1e1c1dd76822ae825983d0e2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 14:51:42 +0700 Subject: [PATCH 29/67] fix(ci): keep a binding-only change out of the Evidence tutorial gate registry-evidence-client-node stayed in EVIDENCE_TUTORIAL_PACKAGES only because that set was built from EVIDENCE_PACKAGES, which the evidence shard's own membership already had to include. A Node-only change therefore replayed the docs Evidence tutorials even though it touches none of their shell commands or fixtures. Exclude binding crates from EVIDENCE_TUTORIAL_PACKAGES only. Source neutrality still runs on the binding crate: evidence_contracts is computed from EVIDENCE_PACKAGES directly, which is untouched. Add a test asserting a binding-only change still runs evidence_contracts and the evidence shard, but not evidence_tutorial. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 12 +++++++++++- .github/scripts/test_ci_changes.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index cbaeed219..6c98ee7f0 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -77,9 +77,19 @@ } ) +# Binding crates (the Node relying-party SDK today; more may join it) stay in +# EVIDENCE_PACKAGES and the `evidence` shard, because their own source is +# covered by check-source-neutrality.sh, and that is what makes +# `evidence_contracts` run the neutrality check against them. A binding-only +# change does not, on its own, replay the docs Evidence tutorials: it touches +# none of a tutorial's shell commands or fixtures, so it is excluded here. +EVIDENCE_BINDING_PACKAGES = frozenset({"registry-evidence-client-node"}) + # The gate also builds and runs `mint`, because one tutorial serves assertions # to a caller holding a real Mint-issued token. -EVIDENCE_TUTORIAL_PACKAGES = EVIDENCE_PACKAGES | frozenset(SHARDS["mint"]) +EVIDENCE_TUTORIAL_PACKAGES = (EVIDENCE_PACKAGES - EVIDENCE_BINDING_PACKAGES) | frozenset( + SHARDS["mint"] +) ROOT_RUST_INPUTS = { "Cargo.lock", diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index af939aa72..9731ca81c 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -221,6 +221,22 @@ def test_evidence_code_and_product_contracts_select_its_shards_and_drift_gate(se {"evidence", "mint"}, ) + def test_binding_only_change_runs_contracts_but_not_the_tutorial_job(self) -> None: + # A Node-binding-only change has no bearing on any tutorial's shell + # commands or fixtures, so it must not replay them; but the binding's + # own source neutrality still needs the contracts gate to run. + outputs = classify( + self.workspace, + ("crates/registry-evidence-client-node/src/lib.rs",), + ) + self.assertFalse(outputs["evidence_tutorial"]) + self.assertTrue(outputs["evidence_contracts"]) + self.assertTrue(outputs["client_bindings"]) + self.assertEqual( + {entry["name"] for entry in outputs["rust_matrix"]["include"]}, + {"evidence"}, + ) + def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertIn("\n evidence-contracts:\n", workflow) From 1713367f74c10409f1c7be443a3602281c7354f8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 15:36:06 +0700 Subject: [PATCH 30/67] fix(evidence): close the node binding's client.js bypass Four confirmed issues from the stage-two review of registry-evidence-client-node: - README's error mapping section described the pre-client.js contract (JSON.parse(error.message)), which now throws a SyntaxError on the common path since client.js reconstructs an EvidenceClientError with ordinary prose in message. Rewrite it to document what ships today. - package.json had no exports map, so a subpath require reached the raw native module and its unpatched JSON-message errors, bypassing client.js entirely. Add an exports map with client.js as the only resolvable entry point, and a drift test proving the guard holds. - package.json had no publish guard, unlike Cargo.toml's publish = false. Add "private": true so the npm side enforces what it already claims about being out of scope for distribution. - The panic-containment doc comment attributed the async-path catch to napi's own catch_unwind, which is not compiled into this build's feature set. Reword it to name tokio's task-level panic isolation, and record the asymmetry that the async path can echo a panic payload to JS while the sync path never does. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client-node/README.md | 21 +++++++++++++----- .../__test__/drift.test.js | 16 ++++++++++++++ .../package.json | 7 ++++++ .../registry-evidence-client-node/src/lib.rs | 22 ++++++++++++++----- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/crates/registry-evidence-client-node/README.md b/crates/registry-evidence-client-node/README.md index 044f0767b..fe06f6b13 100644 --- a/crates/registry-evidence-client-node/README.md +++ b/crates/registry-evidence-client-node/README.md @@ -28,11 +28,22 @@ const verified = client.verifyAsOf(prepared, response, asOfMillis); ### Error mapping -Every failure that crosses from Rust to JS is a thrown `napi::Error` whose -`message` is a JSON-stringified envelope (`kind`, `message`, plus fields -specific to that kind). Callers must `JSON.parse(error.message)` rather than -matching on the error's own type. `kind` is one of: `configuration`, `nonce`, -`token`, `transport`, `denied`, `not_available`, `protocol`, `verification`. +A mapped failure surfaces to a caller as an `EvidenceClientError`, exported +from the package root. Its `kind` is always present; `status`, `code`, +`operation`, `retryAfterSeconds`, `transportKind`, and `tokenKind` are present +when the underlying failure carries them. `message` is human prose, not JSON: +read it, do not parse it. `kind` is one of: `configuration`, `nonce`, `token`, +`transport`, `denied`, `not_available`, `protocol`, `verification`. + +Underneath, the native layer throws every mapped failure as a plain +`napi::Error` whose `message` is a JSON-stringified envelope; `client.js` +parses that envelope and reconstructs it as an `EvidenceClientError`. That JSON +form is how the native layer hands a failure to `client.js`, not a +caller-facing contract: do not `JSON.parse(error.message)`. + +A failure that is not a recognized envelope (a serialization defect, a caught +panic, napi's own argument-type checking) is left exactly as thrown, so it +cannot be mistaken for one of the eight kinds above. The `denied`/`protocol` split is a hazard worth calling out explicitly: HTTP 401, 403, and 429 all map to `denied` regardless of the response body's own diff --git a/crates/registry-evidence-client-node/__test__/drift.test.js b/crates/registry-evidence-client-node/__test__/drift.test.js index ffabf173c..774c074e4 100644 --- a/crates/registry-evidence-client-node/__test__/drift.test.js +++ b/crates/registry-evidence-client-node/__test__/drift.test.js @@ -71,6 +71,22 @@ test('every class client.js exports is declared in client.d.ts or the index.d.ts } }); +test('the exports map is the only resolvable entry point, not index.js', () => { + // `package.json`'s `exports` map exists to stop a caller from reaching the + // raw native module (and its unpatched, JSON-message errors) through a + // subpath require that bypasses `client.js`. A package with an `exports` + // map can self-reference by its own name, so this asserts both halves from + // inside the package itself: the package name resolves to the same wrapper + // `require('..')` gives, and the subpath `index.js` no longer resolves at + // all. + const byName = require('@registrystack/evidence-client'); + assert.equal(byName.EvidenceClient, wrapper.EvidenceClient); + assert.throws( + () => require('@registrystack/evidence-client/index.js'), + (error) => error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED', + ); +}); + test('client.d.ts declares no EvidenceClientError field client.js never sets', () => { // The exact field list `normalize` copies onto a new `EvidenceClientError`, // plus `kind`, which the constructor always sets directly. diff --git a/crates/registry-evidence-client-node/package.json b/crates/registry-evidence-client-node/package.json index 578b2a0f4..5155681f3 100644 --- a/crates/registry-evidence-client-node/package.json +++ b/crates/registry-evidence-client-node/package.json @@ -9,6 +9,13 @@ }, "main": "client.js", "types": "client.d.ts", + "exports": { + ".": { + "types": "./client.d.ts", + "default": "./client.js" + } + }, + "private": true, "files": [ "client.js", "client.d.ts", diff --git a/crates/registry-evidence-client-node/src/lib.rs b/crates/registry-evidence-client-node/src/lib.rs index 2728532ca..4e801563c 100644 --- a/crates/registry-evidence-client-node/src/lib.rs +++ b/crates/registry-evidence-client-node/src/lib.rs @@ -74,12 +74,16 @@ fn to_napi_serialization_error(what: &str, error: serde_json::Error) -> NapiErro /// ordinary rejection instead of letting the unwind cross the FFI boundary, /// which aborts the whole process. /// -/// napi-derive 3.6.2's generated glue carries no panic handling of its own for -/// synchronous `#[napi]` functions; only `napi`'s tokio bridge wraps -/// asynchronous work in `catch_unwind`. Every synchronous entry point in this -/// file (the constructor, `prepare`, `verify`, `verify_as_of`, and the +/// napi-derive 3.6.2's generated glue carries no panic handling of its own +/// for synchronous `#[napi]` functions, so every synchronous entry point in +/// this file (the constructor, `prepare`, `verify`, `verify_as_of`, and the /// `PreparedEvidenceRequest`/`RawEvidenceResponse` getters) routes through -/// this helper. +/// this helper. An asynchronous entry point (`discover`, `fetch_jwks`, +/// `send`, `request_and_verify`) takes a different path: its future runs on +/// tokio, and a panic during a poll is caught by tokio's own task-level panic +/// isolation, not by this helper, then surfaces to napi as a join error that +/// the `tokio_rt`-backed async bridge detects and translates into a rejected +/// promise. /// /// `AssertUnwindSafe` is appropriate here: every call site immediately /// converts a caught panic into a returned `Err` and never inspects or @@ -91,6 +95,14 @@ fn to_napi_serialization_error(what: &str, error: serde_json::Error) -> NapiErro /// is, by construction, a code path nobody validated ahead of time, so its /// payload carries none of the redaction guarantees the rest of this crate's /// error reporting is held to. +/// +/// The asynchronous path is not held to that same guarantee, and this crate +/// does not control it: napi rejects with the panic payload downcast to +/// `&str`, falling back to the fixed literal "Panic in async function" for +/// any other payload type, including the `String` a formatted +/// `panic!("{}", x)` produces. A panic in an asynchronous entry point can +/// therefore echo its own message to JS, unlike `to_napi_panic_error` below, +/// which returns fixed prose regardless of the payload. fn catch_panic(what: &'static str, f: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) .unwrap_or_else(|_payload| Err(to_napi_panic_error(what))) From 996f3464a2e7d7548501affcf1dfa09053f0364e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 19:21:04 +0700 Subject: [PATCH 31/67] feat(evidence): bind the Evidence client for Python Add crates/registry-evidence-client-py, a PyO3 binding over registry-evidence-client, distributed as registry-evidence-client on PyPI and imported as registry_evidence_client. It follows the same pattern as the Node binding: construction, discover/fetch_jwks, the one-send guard, error mapping to a shared EvidenceClientError base with per-kind subclasses, and verify/verify_as_of for a signed response. Wire it into the rest of the repository the way the Node binding already is: the evidence Cargo workspace test shard, the client-bindings CI job (built and tested with the system python3 already on the runner, no actions/setup-python, mirroring the crate's own committed test command), the Evidence source-neutrality check, and the two AGENTS.md repository maps. python/registry_evidence_client/ needed a runtime __init__.py: without one, importing the package resolved as an empty PEP 420 namespace package with the compiled extension unreachable, confirmed by building and installing a wheel before adding the file. This is maturin's ordinary mixed Rust/Python layout, so pyproject.toml's own comment about the extension being the package's __init__ needed correcting to match. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 15 +- .github/workflows/ci.yml | 7 + AGENTS.md | 1 + Cargo.lock | 83 ++ Cargo.toml | 4 + crates/registry-evidence-client-py/.gitignore | 11 + crates/registry-evidence-client-py/Cargo.toml | 53 + crates/registry-evidence-client-py/LICENSE | 201 +++ crates/registry-evidence-client-py/README.md | 173 +++ crates/registry-evidence-client-py/build.rs | 22 + .../pyproject.toml | 28 + .../registry_evidence_client/__init__.py | 18 + .../registry_evidence_client/__init__.pyi | 148 ++ .../src/convert.rs | 1298 +++++++++++++++++ crates/registry-evidence-client-py/src/lib.rs | 563 +++++++ .../tests/fixtures/jwks.json | 11 + .../tests/fixtures/policy.json | 25 + .../tests/fixtures/response.jws.json | 5 + .../tests/golden_fixture.rs | 228 +++ .../tests/happy_path.rs | 475 ++++++ .../tests/python/bootstrap.py | 98 ++ .../tests/python/helpers/fixtures.py | 73 + .../tests/python/helpers/stub_server.py | 132 ++ .../tests/python/test_concurrency.py | 93 ++ .../tests/python/test_construction.py | 73 + .../tests/python/test_discovery.py | 100 ++ .../tests/python/test_drift.py | 156 ++ .../tests/python/test_errors.py | 139 ++ .../tests/python/test_one_send_guard.py | 63 + products/evidence/AGENTS.md | 4 +- .../scripts/check-source-neutrality.sh | 2 + 31 files changed, 4296 insertions(+), 6 deletions(-) create mode 100644 crates/registry-evidence-client-py/.gitignore create mode 100644 crates/registry-evidence-client-py/Cargo.toml create mode 100644 crates/registry-evidence-client-py/LICENSE create mode 100644 crates/registry-evidence-client-py/README.md create mode 100644 crates/registry-evidence-client-py/build.rs create mode 100644 crates/registry-evidence-client-py/pyproject.toml create mode 100644 crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py create mode 100644 crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi create mode 100644 crates/registry-evidence-client-py/src/convert.rs create mode 100644 crates/registry-evidence-client-py/src/lib.rs create mode 100644 crates/registry-evidence-client-py/tests/fixtures/jwks.json create mode 100644 crates/registry-evidence-client-py/tests/fixtures/policy.json create mode 100644 crates/registry-evidence-client-py/tests/fixtures/response.jws.json create mode 100644 crates/registry-evidence-client-py/tests/golden_fixture.rs create mode 100644 crates/registry-evidence-client-py/tests/happy_path.rs create mode 100644 crates/registry-evidence-client-py/tests/python/bootstrap.py create mode 100644 crates/registry-evidence-client-py/tests/python/helpers/fixtures.py create mode 100644 crates/registry-evidence-client-py/tests/python/helpers/stub_server.py create mode 100644 crates/registry-evidence-client-py/tests/python/test_concurrency.py create mode 100644 crates/registry-evidence-client-py/tests/python/test_construction.py create mode 100644 crates/registry-evidence-client-py/tests/python/test_discovery.py create mode 100644 crates/registry-evidence-client-py/tests/python/test_drift.py create mode 100644 crates/registry-evidence-client-py/tests/python/test_errors.py create mode 100644 crates/registry-evidence-client-py/tests/python/test_one_send_guard.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 6c98ee7f0..410011075 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -35,6 +35,7 @@ "registry-evidence", "registry-evidence-client", "registry-evidence-client-node", + "registry-evidence-client-py", "registry-evidence-verifier", "registry-evidencectl", ), @@ -77,13 +78,15 @@ } ) -# Binding crates (the Node relying-party SDK today; more may join it) stay in -# EVIDENCE_PACKAGES and the `evidence` shard, because their own source is -# covered by check-source-neutrality.sh, and that is what makes +# Binding crates (the Node and Python relying-party SDKs today; more may join +# them) stay in EVIDENCE_PACKAGES and the `evidence` shard, because their own +# source is covered by check-source-neutrality.sh, and that is what makes # `evidence_contracts` run the neutrality check against them. A binding-only # change does not, on its own, replay the docs Evidence tutorials: it touches # none of a tutorial's shell commands or fixtures, so it is excluded here. -EVIDENCE_BINDING_PACKAGES = frozenset({"registry-evidence-client-node"}) +EVIDENCE_BINDING_PACKAGES = frozenset( + {"registry-evidence-client-node", "registry-evidence-client-py"} +) # The gate also builds and runs `mint`, because one tutorial serves assertions # to a caller holding a real Mint-issued token. @@ -480,7 +483,9 @@ def classify( ) editors = complete or any(path.startswith("editors/") for path in paths) client_bindings = complete or any( - path.startswith("crates/registry-evidence-client-node/") for path in paths + path.startswith(f"crates/{package}/") + for path in paths + for package in EVIDENCE_BINDING_PACKAGES ) tutorial_infrastructure = any( diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c7b0e273..dfe40237d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1187,6 +1187,13 @@ jobs: npm run check:types cmp ../../LICENSE LICENSE + - name: Build and test the Python binding + working-directory: crates/registry-evidence-client-py + run: | + cargo build --locked -p registry-evidence-client-py --lib --features registry-evidence-client-py/extension-module + python3 -m unittest discover -s tests/python -v + cmp ../../LICENSE LICENSE + ci-result: name: CI result if: always() diff --git a/AGENTS.md b/AGENTS.md index 9c6f93f6d..6a8e83ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ client against a real authorization server. | `crates/registry-evidence-verifier` | Portable Evidence response verification, shared by the runtime and client tooling | | `crates/registry-evidence-client` | Evidence relying-party SDK: requests assertions and verifies them via `registry-evidence-verifier` | | `crates/registry-evidence-client-node` | Node.js binding for `registry-evidence-client`, via napi-rs | +| `crates/registry-evidence-client-py` | Python binding for `registry-evidence-client`, via PyO3 | | `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, incomplete OpenAPI authoring workspaces, fixture runs for complete projects | | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | diff --git a/Cargo.lock b/Cargo.lock index 53f40e2f2..f5d2130d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5160,6 +5160,63 @@ dependencies = [ "cc", ] +[[package]] +name = "pyo3" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc153f5fd745cc038b5eed86622125969f8a39834a57bc96beaaf2512b1da729" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb77d9aa6d647507b55c69ee714d266d84c526c78ff0bc6dd8757f58591e64d1" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3160087aa5733bce7d9a729f8c55f85a2a53b74742a09613178eeebff0722253" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f9d455db760a9a0b0ddeaac25f1390b8a36ba73dfbda9f127cac6fc340d4d5" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e343bcec300ff262f5806a33a4e51b6d097a8a46435f512fcb83e95592581625" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -5587,6 +5644,26 @@ dependencies = [ "url", ] +[[package]] +name = "registry-evidence-client-py" +version = "0.17.0" +dependencies = [ + "base64", + "chrono", + "ed25519-dalek", + "getrandom 0.4.3", + "pyo3", + "pyo3-build-config", + "registry-evidence-client", + "registry-evidence-verifier", + "registry-platform-crypto", + "serde", + "serde_json", + "tokio", + "url", + "wiremock", +] + [[package]] name = "registry-evidence-verifier" version = "0.17.0" @@ -7200,6 +7277,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index 2eab7860e..e8746275d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/registry-evidence", "crates/registry-evidence-client", "crates/registry-evidence-client-node", + "crates/registry-evidence-client-py", "crates/registry-evidence-verifier", "crates/registry-evidencectl", "crates/registry-platform-audit", @@ -47,6 +48,7 @@ registry-config-report = { path = "crates/registry-config-report", version = "0. registry-evidence = { path = "crates/registry-evidence", version = "0.17.0" } registry-evidence-client = { path = "crates/registry-evidence-client", version = "0.17.0" } registry-evidence-client-node = { path = "crates/registry-evidence-client-node", version = "0.17.0" } +registry-evidence-client-py = { path = "crates/registry-evidence-client-py", version = "0.17.0" } registry-evidence-verifier = { path = "crates/registry-evidence-verifier", version = "0.17.0" } registry-language-server = { path = "crates/registry-language-server", version = "0.17.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.17.0" } @@ -118,6 +120,8 @@ p256 = { version = "0.13", features = ["ecdsa"] } pkcs1 = { version = "0.7", features = ["alloc"] } postgres-native-tls = { version = "0.5" } proptest = { version = "1" } +pyo3 = { version = "0.29.1", features = ["abi3-py310"] } +pyo3-build-config = { version = "0.29.1" } rand_core = { version = "0.6", features = ["std"] } rcgen = { version = "0.13", default-features = false, features = ["ring", "zeroize"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } diff --git a/crates/registry-evidence-client-py/.gitignore b/crates/registry-evidence-client-py/.gitignore new file mode 100644 index 000000000..b2e2f7535 --- /dev/null +++ b/crates/registry-evidence-client-py/.gitignore @@ -0,0 +1,11 @@ +# The workspace root's own `/target/` and `__pycache__/`/`*.py[cod]` entries +# already cover cargo's build output and every compiled Python cache in this +# crate; nothing here duplicates those. What is left is local Python tooling +# state that only ever appears under this crate, from a manual `maturin +# develop` or an editable install: `maturin develop`'s own virtualenv and +# dependency lock, and the compiled extension it drops directly into the +# source tree next to the hand-written `__init__.py`/`__init__.pyi`. +*.egg-info/ +.venv/ +uv.lock +python/registry_evidence_client/*.so diff --git a/crates/registry-evidence-client-py/Cargo.toml b/crates/registry-evidence-client-py/Cargo.toml new file mode 100644 index 000000000..9474c4993 --- /dev/null +++ b/crates/registry-evidence-client-py/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "registry-evidence-client-py" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Python binding for the Evidence relying-party client, via PyO3." +readme = "README.md" +repository.workspace = true +publish = false + +[lib] +name = "registry_evidence_client" +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[features] +extension-module = ["pyo3/extension-module"] + +[dependencies] +chrono.workspace = true +# Renamed from the dependency's own package name: this crate's `[lib] name` +# above is also `registry_evidence_client` (the Python module name the spec +# requires), and every file under `tests/` is a separate crate that Cargo +# auto-links against that same `[lib] name` on top of whatever `[dependencies]` +# it needs, so an unaliased entry here would leave every integration test +# depending on two different crates of the identical name (`error[E0464]: +# multiple candidates for rmeta dependency`, which no `use` path syntax can +# resolve). One consistent alias, used everywhere this crate reaches the SDK, +# avoids that rather than special-casing test code against production code. +evidence-client-sdk = { package = "registry-evidence-client", path = "../registry-evidence-client", version = "0.17.0" } +pyo3.workspace = true +registry-platform-crypto.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true + +[build-dependencies] +pyo3-build-config.workspace = true + +[dev-dependencies] +# `auto-initialize` links directly against libpython so `cargo test` can start +# an interpreter itself; the crate's own `[dependencies]` entry above must +# never gain this feature, since `extension-module` (the feature actually +# shipped to callers) is incompatible with linking against libpython. +pyo3 = { workspace = true, features = ["auto-initialize"] } +base64.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +registry-evidence-verifier.workspace = true +serde.workspace = true +wiremock.workspace = true diff --git a/crates/registry-evidence-client-py/LICENSE b/crates/registry-evidence-client-py/LICENSE new file mode 100644 index 000000000..0421f3c2d --- /dev/null +++ b/crates/registry-evidence-client-py/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Jeremi Joslin + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/registry-evidence-client-py/README.md b/crates/registry-evidence-client-py/README.md new file mode 100644 index 000000000..28475d258 --- /dev/null +++ b/crates/registry-evidence-client-py/README.md @@ -0,0 +1,173 @@ +# registry-evidence-client-py + +Python binding for `registry-evidence-client`, the Evidence relying-party +client, via [PyO3](https://pyo3.rs). Every Evidence semantic decision (request +preparation, sending, and verification) is the wrapped Rust crate's own; this +crate is a thin `#[pymodule]` surface plus a JSON conversion layer, and +re-implements none of it. + +Distributed as `registry-evidence-client` on PyPI (matching the crate's own +`[lib] name`), imported as `registry_evidence_client`. Publishing to PyPI is +out of scope for this crate; it currently exists to be built and tested +locally and in CI. + +## Python surface + +Every method below is an ordinary blocking `def`, never `async def`. The +client owns a private current-thread tokio runtime and blocks on it for every +network call, releasing the GIL for the duration (via `py.detach`) so other +Python threads keep running. + +```python +from registry_evidence_client import EvidenceClient + +client = EvidenceClient(base_url, trusted_jwks, token, ...) + +prepared = client.prepare(spec) # synchronous, no I/O +definitions = client.discover() +jwks = client.fetch_jwks() +response = client.send(prepared) +verified = client.verify(prepared, response) +verified = client.request_and_verify(prepared) +verified = client.verify_as_of(prepared, response, as_of_unix_seconds) +``` + +`token` is either a bare string (a static token) or a mapping with exactly one +key, `"private_key_jwt"`. There is no caller-supplied token provider; that is +out of scope for this binding, same as the Node binding. + +## Design notes + +### Error mapping + +A mapped failure surfaces to a caller as an `EvidenceClientError`, exported +from the package root, with one subclass per stable kind: +`ConfigurationError`, `NonceError`, `TokenError`, `TransportError`, +`DeniedError`, `NotAvailableError`, `ProtocolError`, `VerificationError`. Every +instance carries `kind`; `status`, `code`, `operation`, +`retry_after_seconds`, `transport_kind` (only on a `TransportError`), and +`token_kind` (only on a `TokenError`) are set as attributes only when the +underlying failure carries them. `str(error)` is human prose, not JSON: read +it, do not parse it. + +The `denied`/`protocol` split is a hazard worth calling out explicitly: HTTP +401, 403, and 429 all map to `denied` regardless of the response body's own +`code`, while every other non-2xx status (including 400, 500, and anything +else not specifically recognized) maps to `protocol`. A caller that only +checks `status` without also checking `kind` can misclassify a 429 rate limit +as a generic protocol failure, or vice versa. See +`registry-evidence-client`'s `problem.rs` for the authoritative mapping table. + +A response that exceeds `max_response_bytes` maps to `kind: "transport"` with +`transport_kind: "response_too_large"`, not `kind: "protocol"`, even when the +response status itself was a plain 200: the size limit is enforced against the +transport, before any attempt to interpret the body as a problem response. + +No string reaching Python carries unbounded remote text; no exception carries +response bytes, a credential, a header value, a selector value, or a subject +binding. + +### Panics + +PyO3 already catches panics that cross the FFI boundary: every generated +trampoline (behind `#[pymethods]`, `#[pyfunction]`, and `#[pymodule]`) wraps +the call in `std::panic::catch_unwind` and translates a caught panic into +PyO3's own `PanicException`, before this crate adds anything of its own. See +`pyo3-0.29.1/src/impl_/trampoline.rs` (the `trampoline` function) in the +vendored source for the exact mechanism. This crate does not add a second +guard on top; it relies on PyO3's own. One latent panic path exists upstream, +unguarded on purpose: `Ulid::new()`'s call into `rand::rng()` can panic if the +platform RNG is unavailable. A caller who calls a client method from Python +will see that surface as `PanicException`, not a process abort. + +### The `unsafe_code` lint + +Unlike the Node binding (`registry-evidence-client-node`, which opts out of +the workspace's `unsafe_code = "forbid"` because napi-rs's generated glue +contains unsafe code attributed to the invoking crate), this crate inherits +`[lints] workspace = true` unmodified. Confirmed by building +`-p registry-evidence-client-py` both without and with the `extension-module` +feature: PyO3's proc macros do not expand to unsafe code inside this crate; +the actual FFI calls live inside `pyo3`/`pyo3-ffi`'s own compiled sources, +under their own crate's lint settings, not this one's. + +### Nonce and the golden fixture + +`prepare()` generates a fresh request nonce on every call; there is no seam to +inject a fixed nonce from outside. `tests/golden_fixture.rs` and its committed +fixtures under `tests/fixtures/` exist because of this: they pin one specific, +already-issued signed response (with its own fixed nonce baked in) so the +conversion layer's verification path can be exercised deterministically +without needing a live signer. Regenerate the fixture only with: + +```bash +cargo test -p registry-evidence-client-py --test golden_fixture -- --ignored regenerate_golden_fixture +``` + +never by hand-editing the fixture files. The Python test suite deliberately +has no signed-response round trip of its own: `EvidenceClient::send()` never +parses or verifies the response body (only `verify()`/`verify_as_of()` do), so +every Python-level error and construction test can use fake or unsigned +response bytes. The one genuine signed round trip against the real compiled +Python surface lives in `tests/happy_path.rs`, which drives it directly +through PyO3 rather than through a second, separately-maintained signer. + +### `verify_as_of` + +`verify_as_of(prepared, response, as_of_unix_seconds)` judges a response as of +an explicit instant rather than the live clock. A past instant is the +direction that costs something: naming a stale instant accepts an assertion +whose validity interval has since elapsed, because the question asked is +whether it was acceptable *then*, and the answer stays yes forever. A live +trust decision should call `verify`, not this. + +## Building + +Building requires `python3` on `PATH` at build time (PyO3's build script +locates the interpreter to configure the target ABI); this is true for both +`cargo build` and `maturin`. + +For local development, install the package into a virtualenv with: + +```bash +uv run maturin develop +``` + +For `cargo test` on macOS, the `auto-initialize` dev-dependency feature (see +`Cargo.toml`) links the test binary directly against libpython, so the dynamic +linker needs it on its search path, for example: + +```bash +DYLD_LIBRARY_PATH=/Users/jeremi/.local/share/mise/installs/python/3.13.13/lib cargo test -p registry-evidence-client-py +``` + +adjusted to the actual interpreter `cargo test` resolves at build time. This +is not needed to import the built extension module from ordinary Python +(`tests/python/bootstrap.py` never sets it), only for the Rust test binary's +own process startup. + +## Testing + +Rust unit tests (`cargo test -p registry-evidence-client-py`) cover the +conversion layer directly, plus a golden fixture and a live happy-path/ +nonce-mismatch/one-send-guard round trip against the real compiled Python +surface (`tests/happy_path.rs`), driven straight through PyO3 without +shelling out to a `python3` process. + +The Python suite under `tests/python/` (`python3 -m unittest discover -s +crates/registry-evidence-client-py/tests/python`) covers construction +refusals, error mapping for denied/not-available/protocol/transport failures, +discovery (`discover`/`fetch_jwks`) against a stub server, the one-send guard, +a GIL-release concurrency proof, and a stub-drift check against the committed +`.pyi`. Every file in that suite imports `tests/python/bootstrap.py` first, +which runs: + +```bash +cargo build --locked -p registry-evidence-client-py --lib --features registry-evidence-client-py/extension-module +``` + +then copies the resulting dylib to a scratch directory as +`registry_evidence_client.so` and puts that directory on `sys.path`, so the +suite never depends on `maturin`, `pip install -e`, or any packaging step. + +`maturin` itself is a local-development convenience only; CI never invokes it. diff --git a/crates/registry-evidence-client-py/build.rs b/crates/registry-evidence-client-py/build.rs new file mode 100644 index 000000000..134410b81 --- /dev/null +++ b/crates/registry-evidence-client-py/build.rs @@ -0,0 +1,22 @@ +// Emits the macOS `-undefined dynamic_lookup` linker argument PyO3's own +// `extension-module` feature needs for a Python-loadable `cdylib`. +// +// The `extension-module` Cargo feature only tells `pyo3`/`pyo3-ffi`'s own +// build scripts to stop linking this crate against libpython (an extension +// module is `dlopen`ed by an already-running Python process, which supplies +// those symbols itself); it does not, on its own, add the linker flag macOS +// needs to accept a `cdylib` whose Python symbols are left unresolved until +// load time. Compare `crates/registry-evidence-client-node/build.rs`, where +// napi-rs's own `napi_build::setup()` handles the equivalent flag for that +// binding. +// +// This must run only when this crate's own `extension-module` feature is +// enabled: the ordinary build (used by `cargo test`, with the +// `auto-initialize` dev-dependency) embeds Python instead, and linking +// directly against libpython is exactly what that mode needs, so adding this +// flag there would break it. +fn main() { + if std::env::var_os("CARGO_FEATURE_EXTENSION_MODULE").is_some() { + pyo3_build_config::add_extension_module_link_args(); + } +} diff --git a/crates/registry-evidence-client-py/pyproject.toml b/crates/registry-evidence-client-py/pyproject.toml new file mode 100644 index 000000000..6b490278b --- /dev/null +++ b/crates/registry-evidence-client-py/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "registry-evidence-client" +version = "0.17.0" +description = "Python binding for the Evidence relying-party client, via PyO3." +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.10" + +[tool.maturin] +# `python/registry_evidence_client/` already holds a hand-written `__init__.py` +# (plus its `__init__.pyi` stub), so this is maturin's mixed Rust/Python +# layout: the compiled extension is built as the *submodule* +# `registry_evidence_client.registry_evidence_client`, and `__init__.py` +# re-exports its public names with `from .registry_evidence_client import *`. +python-source = "python" +module-name = "registry_evidence_client" +# This crate's own local feature, not `pyo3/extension-module` directly: only +# activating the crate's own `[features]` entry sets the `CARGO_FEATURE_*` +# environment variable `build.rs` gates on to add the macOS linker argument +# `pyo3`'s `extension-module` feature needs (see `build.rs`'s own comment). +# Passing the dependency-qualified feature straight through would enable the +# same `pyo3` feature without ever setting that variable, and the build would +# fail with the same unresolved-symbol linker error `build.rs` exists to fix. +features = ["extension-module"] diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py new file mode 100644 index 000000000..3617f8435 --- /dev/null +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py @@ -0,0 +1,18 @@ +"""Python binding for the Evidence relying-party client, via PyO3. + +Without this file, `python/registry_evidence_client/` is just a directory +holding a type stub next to a compiled extension of the same name, which +Python's import system treats as an empty PEP 420 namespace package: nothing +inside the compiled module is reachable. This file makes the directory an +ordinary package whose contents are the compiled module's own, matching +maturin's standard mixed Rust/Python layout. +""" + +from .registry_evidence_client import * # noqa: F401,F403 + +# `import *` above also binds the submodule's own name (`registry_evidence_client`) +# into this package's namespace, alongside the classes and exceptions it +# actually exports; drop it so the public surface matches the committed +# `__init__.pyi` exactly, with nothing extra for the stub-drift test to +# special-case. +del registry_evidence_client diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi new file mode 100644 index 000000000..d17c5c7d5 --- /dev/null +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi @@ -0,0 +1,148 @@ +"""Type stubs for the compiled `registry_evidence_client` extension module. + +Hand-written, not generated: PyO3 does not emit a `.pyi` on its own. Kept +honest by `tests/python/test_drift.py`, which introspects the compiled +module's real classes and asserts this file names exactly the same methods +and attributes, in both directions. + +Every "JSON-shaped" parameter and return value below (`trusted_jwks`, `token`, +`spec`, and every returned document) crosses the FFI boundary as a plain +Python object graph built from `dict`/`list`/`str`/`int`/`float`/`bool`/ +`None`, mirroring the wire JSON it stands for. `Any` says exactly that, +rather than a shape this binding does not itself constrain any further than +"valid JSON". + +Every method on `EvidenceClient` is an ordinary, blocking call: the client +owns a private tokio runtime and blocks on it for every network call, +releasing the GIL for the duration so other Python threads keep running. +None of this crosses into `asyncio`; there is no `async def` anywhere here. +""" + +from typing import Any, Optional, Sequence, Union + +class EvidenceClientError(Exception): + """Base exception for every failure this client reports. + + `kind` is always present, one of "configuration", "nonce", "token", + "transport", "denied", "not_available", "protocol", or "verification". + Branch on `kind`, never on the rendered message, which this crate does + not freeze. `status`, `code`, `operation`, `retry_after_seconds`, + `transport_kind`, and `token_kind` are present only when the underlying + failure carries them; every other case leaves them `None`. + + No attribute here ever carries response bytes, a credential, a header + value, a selector value, or a subject binding. + """ + + kind: str + status: Optional[int] + code: Optional[str] + operation: Optional[str] + retry_after_seconds: Optional[int] + transport_kind: Optional[str] + token_kind: Optional[str] + +class ConfigurationError(EvidenceClientError): + """The client cannot be used as configured, or a prepared request already + spent the single send it allows.""" + + ... + +class NonceError(EvidenceClientError): + """The request nonce could not be generated.""" + + ... + +class TokenError(EvidenceClientError): + """The credential presented to the deployment could not be obtained. See + the `token_kind` attribute for the specific cause.""" + + ... + +class TransportError(EvidenceClientError): + """The exchange with the deployment failed below the HTTP layer. See the + `transport_kind` attribute for the specific cause.""" + + ... + +class DeniedError(EvidenceClientError): + """The deployment refused the request with a contract-coded problem + response. See the `status`, `code`, and `retry_after_seconds` + attributes.""" + + ... + +class NotAvailableError(EvidenceClientError): + """The deployment answered that no evidence is available for this + request.""" + + ... + +class ProtocolError(EvidenceClientError): + """The deployment answered outside its contract: an uncoded refusal, or a + response this client could not parse. See the `status` attribute.""" + + ... + +class VerificationError(EvidenceClientError): + """A signed response failed offline verification against the closed + policy. See the `code` attribute for the verifier's own kind.""" + + ... + +class PreparedEvidenceRequest: + """One request, closed and nonce-bearing, before any byte has left the + process. No public constructor: obtain one only from + `EvidenceClient.prepare`. Good for exactly one `send` or + `request_and_verify` call.""" + + request_nonce: str + policy_document: Any + subject_expectations: Union[str, Sequence[Any]] + +class RawEvidenceResponse: + """A signed response, read but not yet judged. No public constructor and + no attributes: obtain one only from `EvidenceClient.send`. Nothing in it + has been trusted yet; `verify` is what judges it.""" + + ... + +class VerifiedEvidence: + """A response that satisfied every expectation.""" + + evidence: Any + operation: Optional[str] + pinned_subject_expectations: Any + +class EvidenceClient: + """A relying party's connection to one Evidence deployment.""" + + def __init__( + self, + base_url: str, + trusted_jwks: Any, + token: Any, + request_timeout_seconds: Optional[float] = ..., + connect_timeout_seconds: Optional[float] = ..., + user_agent: Optional[str] = ..., + trusted_root_certificates: Optional[bytes] = ..., + max_response_bytes: Optional[int] = ..., + ) -> None: ... + def prepare(self, spec: Any) -> PreparedEvidenceRequest: ... + def discover(self) -> Any: ... + def fetch_jwks(self) -> Any: ... + def send(self, prepared: PreparedEvidenceRequest) -> RawEvidenceResponse: ... + def verify( + self, + prepared: PreparedEvidenceRequest, + response: RawEvidenceResponse, + ) -> VerifiedEvidence: ... + def request_and_verify( + self, prepared: PreparedEvidenceRequest + ) -> VerifiedEvidence: ... + def verify_as_of( + self, + prepared: PreparedEvidenceRequest, + response: RawEvidenceResponse, + as_of_unix_seconds: float, + ) -> VerifiedEvidence: ... diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs new file mode 100644 index 000000000..8dd8c6f53 --- /dev/null +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -0,0 +1,1298 @@ +//! Python-value <-> Rust conversions for the Evidence Python binding. +//! +//! Every function here is a plain Rust function, over `serde_json::Value` or +//! PyO3's `Bound<'_, PyAny>`, so the whole conversion layer is unit-testable +//! with `cargo test` and carries no dependency on the `pyo3::pymodule`/ +//! `pyclass` machinery. `src/lib.rs` is the only file in this crate that +//! defines the Python module surface; it calls into this module for every +//! conversion and reports failures through [`map_client_error`], +//! [`map_conversion_error`], and [`map_config_error`]. +//! +//! Unlike the Node binding (`crates/registry-evidence-client-node`), which +//! gets a `JsUnknown` <-> `serde_json::Value` bridge for free from napi's +//! `serde-json` feature, PyO3 has no such built-in conversion, and the crate +//! deliberately does not add one (`pythonize` or similar): [`python_to_json`] +//! and [`json_to_python`] are the small explicit recursive functions that +//! stand in for it. + +use std::{fmt, sync::Arc, time::Duration}; + +use chrono::{DateTime, Utc}; +use evidence_client_sdk::{ + AssuranceProfile, Evidence, EvidenceClientConfig, EvidenceClientError, EvidenceRequestSpec, + ExpectedOutputDocument, ExpectedSubjectDocument, JwksDocument, PrivateKeyJwt, + PrivateKeyJwtConfig, SelectorValue, StaticToken, SubjectExpectations, SubjectRequest, + TokenError, TokenProvider, +}; +use pyo3::{ + prelude::*, + types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}, + IntoPyObjectExt, +}; +use registry_platform_crypto::PrivateJwk; +use serde_json::{Map, Value}; +use url::Url; + +/// A Python-supplied value did not have the shape this binding requires. +/// +/// This is distinct from [`EvidenceClientError`]: it is refused before any +/// client-level Rust type exists, so it carries its own message rather than +/// borrowing the fixed `&'static str` reason of a type that cannot describe a +/// dynamically built Python shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversionError(pub String); + +impl ConversionError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ConversionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ConversionError {} + +/// Building a client configuration mixes pure shape conversion with a +/// genuine, semantically real credential construction +/// ([`PrivateKeyJwt::new`]), so a failure may come from either stage. Keeping +/// them distinct lets a caller (and a test) tell "the Python object was +/// malformed" apart from "the configuration it described is unusable." +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + Shape(ConversionError), + Client(EvidenceClientError), +} + +impl From for ConfigError { + fn from(error: ConversionError) -> Self { + Self::Shape(error) + } +} + +impl From for ConfigError { + fn from(error: TokenError) -> Self { + Self::Client(EvidenceClientError::Token(error)) + } +} + +fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a Map, ConversionError> { + value + .as_object() + .ok_or_else(|| ConversionError::new(format!("{what} must be an object"))) +} + +fn required_string(object: &Map, field: &str) -> Result { + object + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a string"))) +} + +fn required_u64(object: &Map, field: &str) -> Result { + object + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a non-negative integer"))) +} + +fn optional_string( + object: &Map, + field: &str, +) -> Result, ConversionError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) => Ok(Some(text.clone())), + Some(_) => Err(ConversionError::new(format!("`{field}` must be a string"))), + } +} + +fn optional_i64(object: &Map, field: &str) -> Result, ConversionError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value.as_i64().map(Some).ok_or_else(|| { + ConversionError::new(format!("`{field}` must be an integer that fits in 64 bits")) + }), + } +} + +fn optional_f64(object: &Map, field: &str) -> Result, ConversionError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_f64() + .map(Some) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a number"))), + } +} + +fn parse_url(value: &str, what: &str) -> Result { + Url::parse(value).map_err(|_| ConversionError::new(format!("{what} must be a valid URL"))) +} + +/// Turn a caller-supplied number of seconds into a [`Duration`]. +/// +/// Python's idiom for a timeout is a float number of seconds, unlike the +/// Node binding's millisecond integers. `Duration::from_secs_f64` panics on a +/// negative, infinite, or `NaN` input, so those are refused here first. +fn duration_from_seconds(seconds: f64, what: &str) -> Result { + if !seconds.is_finite() || seconds < 0.0 { + return Err(ConversionError::new(format!( + "{what} must be a finite, non-negative number of seconds" + ))); + } + Ok(Duration::from_secs_f64(seconds)) +} + +/// Turn a caller-supplied UNIX timestamp (seconds, as `datetime.timestamp()` +/// yields) into the instant [`evidence_client_sdk::EvidenceClient::verify_as_of`] +/// takes. +pub fn datetime_from_unix_seconds(seconds: f64) -> Result, ConversionError> { + if !seconds.is_finite() { + return Err(ConversionError::new( + "a timestamp must be a finite number of seconds since the UNIX epoch", + )); + } + let whole_seconds = seconds.floor(); + let nanos = ((seconds - whole_seconds) * 1_000_000_000.0).round() as u32; + DateTime::from_timestamp(whole_seconds as i64, nanos) + .ok_or_else(|| ConversionError::new("a timestamp is outside the representable range")) +} + +/// Convert a Python value to a [`serde_json::Value`]. +/// +/// The Python `bool` type is a subtype of `int`, so a boolean value must be +/// recognized before an integer downcast is attempted; checking in the +/// opposite order would silently turn `True`/`False` into `1`/`0`. +pub fn python_to_json(value: &Bound<'_, PyAny>) -> Result { + if value.is_none() { + return Ok(Value::Null); + } + if let Ok(flag) = value.cast::() { + return Ok(Value::Bool(flag.is_true())); + } + if let Ok(integer) = value.cast::() { + let extracted: i64 = integer + .extract() + .map_err(|_| ConversionError::new("an integer value must fit in 64 bits"))?; + return Ok(Value::from(extracted)); + } + if let Ok(float_value) = value.cast::() { + let extracted: f64 = float_value + .extract() + .map_err(|_| ConversionError::new("a floating-point value could not be read"))?; + let number = serde_json::Number::from_f64(extracted) + .ok_or_else(|| ConversionError::new("a floating-point value must be finite"))?; + return Ok(Value::Number(number)); + } + if let Ok(text) = value.cast::() { + let extracted = text + .to_str() + .map_err(|_| ConversionError::new("a string value must be valid Unicode"))?; + return Ok(Value::String(extracted.to_owned())); + } + if let Ok(list) = value.cast::() { + let items = list + .iter() + .map(|item| python_to_json(&item)) + .collect::, _>>()?; + return Ok(Value::Array(items)); + } + if let Ok(tuple) = value.cast::() { + let items = tuple + .iter() + .map(|item| python_to_json(&item)) + .collect::, _>>()?; + return Ok(Value::Array(items)); + } + if let Ok(dict) = value.cast::() { + let mut object = Map::new(); + for (key, value) in dict.iter() { + let key_text: String = key + .cast::() + .map_err(|_| ConversionError::new("a mapping key must be a string"))? + .to_str() + .map_err(|_| ConversionError::new("a mapping key must be valid Unicode"))? + .to_owned(); + object.insert(key_text, python_to_json(&value)?); + } + return Ok(Value::Object(object)); + } + Err(ConversionError::new( + "a value of this Python type cannot be converted", + )) +} + +/// Convert a [`serde_json::Value`] to a Python value. +/// +/// This is how the verified [`Evidence`] payload, the policy document, and +/// every other Rust-owned document crosses to Python: through +/// [`evidence_to_json`] (or an ordinary `serde_json::to_value`) and then this +/// function, never through a conversion dependency. +pub fn json_to_python<'py>(py: Python<'py>, value: &Value) -> PyResult> { + match value { + Value::Null => Ok(py.None().into_bound(py)), + Value::Bool(flag) => (*flag).into_bound_py_any(py), + Value::Number(number) => { + if let Some(whole) = number.as_i64() { + whole.into_bound_py_any(py) + } else if let Some(whole) = number.as_u64() { + whole.into_bound_py_any(py) + } else if let Some(fractional) = number.as_f64() { + fractional.into_bound_py_any(py) + } else { + Err(pyo3::exceptions::PyValueError::new_err( + "a JSON number could not be represented in Python", + )) + } + } + Value::String(text) => text.as_str().into_bound_py_any(py), + Value::Array(items) => { + let converted = items + .iter() + .map(|item| json_to_python(py, item)) + .collect::>>()?; + Ok(PyList::new(py, converted)?.into_any()) + } + Value::Object(object) => { + let dict = PyDict::new(py); + for (key, value) in object { + dict.set_item(key, json_to_python(py, value)?)?; + } + Ok(dict.into_any()) + } + } +} + +/// The three scalar shapes a selector value may take on the wire, read off a +/// Python value. +/// +/// A float, a mapping, `None`, and an integer literal too large for `i64` are +/// all refused here: the request contract's own numeric bound +/// (`MINIMUM_SELECTOR_INTEGER..=MAXIMUM_SELECTOR_INTEGER`) is enforced later, +/// by the real `EvidenceClient::prepare` call, once a genuine +/// `EvidenceRequestSpec` exists. +fn selector_value_from_json(value: &Value) -> Result { + match value { + Value::String(text) => Ok(SelectorValue::from(text.as_str())), + Value::Bool(flag) => Ok(SelectorValue::from(*flag)), + Value::Number(number) => number.as_i64().map(SelectorValue::from).ok_or_else(|| { + ConversionError::new( + "a selector integer value must fit in 64 bits with no fractional part", + ) + }), + _ => Err(ConversionError::new( + "a selector value must be a string, an integer, or a boolean", + )), + } +} + +fn subject_request_from_json(value: &Value) -> Result { + let object = as_object(value, "a subject request")?; + let role = required_string(object, "role")?; + let selector_profile = required_string(object, "selector_profile")?; + let selector_values = match object.get("selector_values") { + None | Some(Value::Null) => None, + Some(Value::Object(values)) => { + let mut pairs = Vec::with_capacity(values.len()); + for (name, value) in values { + pairs.push((name.clone(), selector_value_from_json(value)?)); + } + Some(pairs) + } + Some(_) => { + return Err(ConversionError::new( + "`selector_values` must be a mapping of field names to values", + )) + } + }; + Ok(SubjectRequest { + role, + selector_profile, + selector_values, + }) +} + +/// `subject_expectations` accepts a bare sequence of `{"role", "binding"}` +/// mappings, or the literal string `"accept_first_use"`. There is no third +/// shape, matching [`SubjectExpectations`] having no third variant. +/// +/// This is deliberately not Node's `{"pinned": [...]}`/`"acceptFirstUse"` +/// wrapper shape: a Python caller passing a list of expectations does not +/// need a wrapper key to name what is already the only kind of item the list +/// can hold. +pub fn subject_expectations_from_json( + value: &Value, +) -> Result { + match value { + Value::String(tag) if tag == "accept_first_use" => Ok(SubjectExpectations::AcceptFirstUse), + Value::Array(entries) => { + let mut subjects = Vec::with_capacity(entries.len()); + for entry in entries { + let entry = as_object(entry, "a pinned subject expectation")?; + subjects.push(ExpectedSubjectDocument { + role: required_string(entry, "role")?, + binding: required_string(entry, "binding")?, + }); + } + Ok(SubjectExpectations::Pinned(subjects)) + } + _ => Err(ConversionError::new( + "`subject_expectations` must be \"accept_first_use\" or a sequence of {\"role\", \"binding\"} mappings", + )), + } +} + +/// The inverse of [`subject_expectations_from_json`]. Infallible: every +/// [`SubjectExpectations`] value already came from a caller's own request, and +/// both variants have an unambiguous JSON rendering. +pub fn subject_expectations_to_json(expectations: &SubjectExpectations) -> Value { + match expectations { + SubjectExpectations::AcceptFirstUse => Value::String("accept_first_use".to_owned()), + SubjectExpectations::Pinned(subjects) => Value::Array( + subjects + .iter() + .map(|subject| { + serde_json::json!({ + "role": subject.role, + "binding": subject.binding, + }) + }) + .collect(), + ), + } +} + +fn expected_outputs_from_json( + value: &Value, +) -> Result, ConversionError> { + serde_json::from_value(value.clone()) + .map_err(|error| ConversionError::new(format!("`expected_outputs` is invalid: {error}"))) +} + +fn assurance_profile_from_json(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|error| { + ConversionError::new(format!("`expected_assurance_profile` is invalid: {error}")) + }) +} + +/// Build the specification [`evidence_client_sdk::EvidenceClient::prepare`] +/// validates. Only shape is checked here: an empty identifier, an +/// out-of-range count, or any other business rule is the real client's own +/// refusal, raised once a genuine `EvidenceRequestSpec` exists. +pub fn spec_from_json(value: &Value) -> Result { + let object = as_object(value, "a request specification")?; + + let subjects_json = object + .get("subjects") + .and_then(Value::as_array) + .ok_or_else(|| ConversionError::new("`subjects` must be a sequence"))?; + let subjects = subjects_json + .iter() + .map(subject_request_from_json) + .collect::, _>>()?; + + let expected_outputs_json = object + .get("expected_outputs") + .ok_or_else(|| ConversionError::new("`expected_outputs` must be present"))?; + let expected_outputs = expected_outputs_from_json(expected_outputs_json)?; + + let expected_assurance_profile_json = object + .get("expected_assurance_profile") + .ok_or_else(|| ConversionError::new("`expected_assurance_profile` must be present"))?; + let expected_assurance_profile = assurance_profile_from_json(expected_assurance_profile_json)?; + + let subject_expectations_json = object + .get("subject_expectations") + .ok_or_else(|| ConversionError::new("`subject_expectations` must be present"))?; + let subject_expectations = subject_expectations_from_json(subject_expectations_json)?; + + Ok(EvidenceRequestSpec { + requirement: required_string(object, "requirement")?, + purpose: required_string(object, "purpose")?, + audience: required_string(object, "audience")?, + evidence_type: required_string(object, "evidence_type")?, + issued_by: required_string(object, "issued_by")?, + provided_by: required_string(object, "provided_by")?, + configuration_revision: required_string(object, "configuration_revision")?, + expected_assurance_profile, + subjects, + expected_outputs, + maximum_assertion_lifetime_seconds: required_u64( + object, + "maximum_assertion_lifetime_seconds", + )?, + clock_skew_seconds: required_u64(object, "clock_skew_seconds")?, + subject_expectations, + }) +} + +/// `token["private_key_jwt"]`'s own shape mirrors [`PrivateKeyJwtConfig`]'s +/// builder surface: one required endpoint, client identifier, and signing +/// key, plus the optional knobs the Rust type exposes for its own outbound +/// exchange with the token endpoint. +/// +/// Deliberately absent: a nested `trusted_root_certificates`. The top-level +/// constructor accepts it as genuine `bytes`, bypassing the JSON bridge +/// entirely, but this nested shape arrives already folded into a +/// `serde_json::Value` by the caller in `src/lib.rs`, which has no way to +/// carry raw bytes. Supporting a second, independent trust anchor for the +/// token endpoint specifically is a real gap this task did not ask to close; +/// it is deferred rather than worked around. +fn private_key_jwt_provider_from_json(value: &Value) -> Result { + let object = as_object(value, "`token[\"private_key_jwt\"]`").map_err(ConfigError::Shape)?; + + let token_endpoint = parse_url( + &required_string(object, "token_endpoint").map_err(ConfigError::Shape)?, + "`token[\"private_key_jwt\"][\"token_endpoint\"]`", + ) + .map_err(ConfigError::Shape)?; + let client_id = required_string(object, "client_id").map_err(ConfigError::Shape)?; + + let client_key_json = object.get("client_key").ok_or_else(|| { + ConfigError::Shape(ConversionError::new( + "`token[\"private_key_jwt\"][\"client_key\"]` must be present", + )) + })?; + let client_key_text = serde_json::to_string(client_key_json).map_err(|error| { + ConfigError::Shape(ConversionError::new(format!( + "`token[\"private_key_jwt\"][\"client_key\"]` is invalid: {error}" + ))) + })?; + let client_key = PrivateJwk::parse(&client_key_text).map_err(|error| { + ConfigError::Shape(ConversionError::new(format!( + "`token[\"private_key_jwt\"][\"client_key\"]` is invalid: {error}" + ))) + })?; + + let mut config = PrivateKeyJwtConfig::new(token_endpoint, client_id, client_key); + if let Some(audience) = optional_string(object, "audience").map_err(ConfigError::Shape)? { + config = config.with_audience(audience); + } + if let Some(seconds) = + optional_i64(object, "assertion_lifetime_seconds").map_err(ConfigError::Shape)? + { + config = config.with_assertion_lifetime_seconds(seconds); + } + if let Some(seconds) = + optional_i64(object, "refresh_margin_seconds").map_err(ConfigError::Shape)? + { + config = config.with_refresh_margin_seconds(seconds); + } + if let Some(seconds) = + optional_f64(object, "request_timeout_seconds").map_err(ConfigError::Shape)? + { + let timeout = duration_from_seconds( + seconds, + "`token[\"private_key_jwt\"][\"request_timeout_seconds\"]`", + ) + .map_err(ConfigError::Shape)?; + config = config.with_request_timeout(timeout); + } + if let Some(seconds) = + optional_f64(object, "connect_timeout_seconds").map_err(ConfigError::Shape)? + { + let timeout = duration_from_seconds( + seconds, + "`token[\"private_key_jwt\"][\"connect_timeout_seconds\"]`", + ) + .map_err(ConfigError::Shape)?; + config = config.with_connect_timeout(timeout); + } + if let Some(user_agent) = optional_string(object, "user_agent").map_err(ConfigError::Shape)? { + config = config.with_user_agent(user_agent); + } + + PrivateKeyJwt::new(config).map_err(ConfigError::from) +} + +/// `token` is either a plain string, the static credential, or an object +/// carrying exactly one key, `private_key_jwt`. Distinct from Node's +/// `{"static": ...}`/`{"privateKeyJwt": ...}` shape, which wraps both cases; +/// a Python caller with a static token has no reason to name the case it +/// picked when a bare string already says so. +fn token_provider_from_json(value: &Value) -> Result, ConfigError> { + match value { + Value::String(text) => { + let provider = StaticToken::new(text.as_str())?; + Ok(Arc::new(provider)) + } + Value::Object(object) => { + if object.len() != 1 { + return Err(ConfigError::Shape(ConversionError::new( + "`token` must be a string or an object with exactly one key, \"private_key_jwt\"", + ))); + } + let inner = object.get("private_key_jwt").ok_or_else(|| { + ConfigError::Shape(ConversionError::new( + "`token` object must carry \"private_key_jwt\"", + )) + })?; + let provider = private_key_jwt_provider_from_json(inner)?; + Ok(Arc::new(provider)) + } + _ => Err(ConfigError::Shape(ConversionError::new( + "`token` must be a string or an object with \"private_key_jwt\"", + ))), + } +} + +/// Build the configuration [`evidence_client_sdk::EvidenceClient::new`] +/// validates. Only shape is checked here (a missing field, a malformed URL, a +/// malformed key); the pinned-key-set, transport, and timeout business rules +/// are the real client's own refusal, raised once a genuine +/// `EvidenceClientConfig` exists. +/// +/// `trusted_root_certificates` bypasses the JSON bridge entirely: PyO3 +/// auto-extracts Python `bytes` to `Vec`, so `src/lib.rs` passes it here +/// as a genuine Rust value rather than folding it into `trusted_jwks` or +/// `token`'s `serde_json::Value`. +#[allow(clippy::too_many_arguments)] +pub fn config_from_parts( + base_url: &str, + trusted_jwks: &Value, + token: &Value, + request_timeout_seconds: Option, + connect_timeout_seconds: Option, + user_agent: Option, + trusted_root_certificates: Option>, + max_response_bytes: Option, +) -> Result { + let base_url = parse_url(base_url, "`base_url`").map_err(ConfigError::Shape)?; + + let trusted_jwks: JwksDocument = + serde_json::from_value(trusted_jwks.clone()).map_err(|error| { + ConfigError::Shape(ConversionError::new(format!( + "`trusted_jwks` is invalid: {error}" + ))) + })?; + + let token_provider = token_provider_from_json(token)?; + + let mut config = EvidenceClientConfig::new(base_url, token_provider, trusted_jwks); + + if let Some(seconds) = request_timeout_seconds { + let timeout = duration_from_seconds(seconds, "`request_timeout_seconds`") + .map_err(ConfigError::Shape)?; + config = config.with_request_timeout(timeout); + } + if let Some(seconds) = connect_timeout_seconds { + let timeout = duration_from_seconds(seconds, "`connect_timeout_seconds`") + .map_err(ConfigError::Shape)?; + config = config.with_connect_timeout(timeout); + } + if let Some(user_agent) = user_agent { + config = config.with_user_agent(user_agent); + } + if let Some(pem_bundle) = trusted_root_certificates { + config = config.with_trusted_root_certificates(pem_bundle); + } + if let Some(max_bytes) = max_response_bytes { + config = config.with_max_response_bytes(max_bytes); + } + + Ok(config) +} + +/// The verified payload crosses to Python through this, never through +/// `Debug`. +pub fn evidence_to_json(evidence: &Evidence) -> Result { + serde_json::to_value(evidence).map_err(|error| { + ConversionError::new(format!( + "the verified evidence payload could not be serialized: {error}" + )) + }) +} + +/// One mapped failure, ready to become a Python exception. +/// +/// Unlike the Node binding, which serializes this shape into one JSON string +/// and uses it as the thrown error's `message` (so a caller can +/// `JSON.parse(error.message)`), the Python exception classes carry `kind`, +/// `status`, `code`, `operation`, `retry_after_seconds`, `transport_kind`, and +/// `token_kind` as separate attributes, and `message` is always `Display` +/// text over the source failure, never a JSON envelope. `src/lib.rs` reads +/// this struct's fields directly when constructing the exception instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MappedError { + pub kind: &'static str, + pub message: String, + pub status: Option, + pub code: Option, + pub operation: Option, + pub retry_after_seconds: Option, + pub transport_kind: Option<&'static str>, + pub token_kind: Option<&'static str>, +} + +impl MappedError { + fn bare(kind: &'static str, message: String) -> Self { + Self { + kind, + message, + status: None, + code: None, + operation: None, + retry_after_seconds: None, + transport_kind: None, + token_kind: None, + } + } +} + +/// A shape-level failure reports the same stable envelope every mapped +/// failure uses, so a caller need not special-case where a failure +/// originated. There is no dedicated "shape" kind among the eight the runtime +/// client defines; a Python caller that supplied an unusable shape is, from +/// the caller's side, exactly the "the client cannot be used as configured" +/// case. +pub fn map_conversion_error(error: &ConversionError) -> MappedError { + MappedError::bare("configuration", error.to_string()) +} + +pub fn map_config_error(error: &ConfigError) -> MappedError { + match error { + ConfigError::Shape(shape) => map_conversion_error(shape), + ConfigError::Client(client) => map_client_error(client), + } +} + +/// Map any [`EvidenceClientError`] to a [`MappedError`]. +/// +/// `code` is deliberately overloaded: a `Denied`/`Protocol` wire code, a +/// `Token::Refused` OAuth code, and a `Verification` failure's own kind +/// string all travel in the same field, since a caller branches on `kind` +/// first and `code` only refines it. `token_kind` is `Token`'s own analogous +/// second discriminant: every `TokenError` carries one, from +/// [`TokenError::kind`], alongside whichever further sub-fields +/// (`transport_kind`, `code`, `status`) that specific variant also carries. +/// +/// `code` and `operation` are already bounded by the wrapped crate before +/// either ever reaches an `EvidenceClientError` variant +/// (`evidence_client_sdk::problem::is_contract_code` and +/// `sanitized_operation`, at most 64 bytes of lowercase snake case and 64 +/// ASCII alphanumerics respectively): this function passes them through +/// unchanged rather than re-bounding them. +/// +/// Never included: response bytes, a credential, a header value, a selector +/// value, or a subject binding. Every message here is `Display` text over +/// fixed, non-secret reasons; none of the eight kinds can carry one of those. +pub fn map_client_error(error: &EvidenceClientError) -> MappedError { + let mut mapped = MappedError::bare(error.kind(), error.to_string()); + mapped.operation = error.operation().map(str::to_owned); + + match error { + EvidenceClientError::Configuration { .. } | EvidenceClientError::Nonce(_) => {} + EvidenceClientError::Token(token_error) => insert_token_fields(&mut mapped, token_error), + EvidenceClientError::Transport { kind } => { + mapped.transport_kind = Some(kind.kind()); + } + EvidenceClientError::Denied { + status, + code, + retry_after_seconds, + .. + } => { + mapped.status = Some(*status); + mapped.code = Some(code.clone()); + mapped.retry_after_seconds = *retry_after_seconds; + } + EvidenceClientError::NotAvailable { .. } => {} + EvidenceClientError::Protocol { + status, + code, + retry_after_seconds, + .. + } => { + mapped.status = Some(*status); + mapped.code = code.clone(); + mapped.retry_after_seconds = *retry_after_seconds; + } + EvidenceClientError::Verification(verification_error) => { + mapped.code = Some(verification_error.kind().to_owned()); + } + // `EvidenceClientError` is `#[non_exhaustive]`: a variant this crate + // does not yet know about still maps, with only `kind`, `message`, and + // whatever `operation()` reports for it. + _ => {} + } + + mapped +} + +fn insert_token_fields(mapped: &mut MappedError, error: &TokenError) { + mapped.token_kind = Some(error.kind()); + match error { + TokenError::Unavailable | TokenError::Invalid { .. } | TokenError::Configuration { .. } => { + } + TokenError::Transport { kind } => { + mapped.transport_kind = Some(kind.kind()); + } + TokenError::Refused { code } => { + mapped.code = Some(code.as_str().to_owned()); + } + TokenError::Protocol { status } => { + mapped.status = Some(*status); + } + // `TokenError` is `#[non_exhaustive]`. + _ => {} + } +} + +#[cfg(test)] +mod tests { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use ed25519_dalek::SigningKey; + use evidence_client_sdk::{NonceError, TransportKind}; + use pyo3::{ + types::{PyDict, PyList}, + Python, + }; + use registry_evidence_verifier::verifier::VerificationError; + + use super::*; + + fn generated_private_jwk_json() -> Value { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the host supplies randomness"); + let signing_key = SigningKey::from_bytes(&seed); + serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": "convert-test-key-1", + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + }) + } + + fn valid_spec_json() -> Value { + serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "evidence_type": "urn:example:evidence-type:v1", + "issued_by": "urn:example:issuer", + "provided_by": "urn:example:provider", + "configuration_revision": "sha256:0000000000000000000000000000000000000000000000000000000000000", + "expected_assurance_profile": "local", + "subjects": [ + { "role": "subject", "selector_profile": "national-id" } + ], + "expected_outputs": [ + { "concept": "urn:example:concept:status-holds", "form": "boolean" } + ], + "maximum_assertion_lifetime_seconds": 300, + "clock_skew_seconds": 30, + "subject_expectations": "accept_first_use", + }) + } + + #[test] + fn python_to_json_distinguishes_bool_from_int() { + Python::attach(|py| { + let true_value = true.into_bound_py_any(py).unwrap(); + assert_eq!(python_to_json(&true_value).unwrap(), Value::Bool(true)); + + let false_value = false.into_bound_py_any(py).unwrap(); + assert_eq!(python_to_json(&false_value).unwrap(), Value::Bool(false)); + + let one = 1_i64.into_bound_py_any(py).unwrap(); + assert_eq!(python_to_json(&one).unwrap(), Value::from(1_i64)); + }); + } + + #[test] + fn python_to_json_converts_every_scalar_shape() { + Python::attach(|py| { + assert_eq!( + python_to_json(&py.None().into_bound(py)).unwrap(), + Value::Null + ); + assert_eq!( + python_to_json(&"text".into_bound_py_any(py).unwrap()).unwrap(), + Value::String("text".to_owned()) + ); + assert_eq!( + python_to_json(&2.5_f64.into_bound_py_any(py).unwrap()).unwrap(), + Value::from(2.5_f64) + ); + }); + } + + #[test] + fn python_to_json_converts_lists_and_tuples() { + Python::attach(|py| { + let list = PyList::new(py, [1_i64, 2, 3]).unwrap(); + assert_eq!( + python_to_json(&list.into_any()).unwrap(), + Value::Array(vec![Value::from(1), Value::from(2), Value::from(3)]) + ); + + let tuple = pyo3::types::PyTuple::new(py, ["a", "b"]).unwrap(); + assert_eq!( + python_to_json(&tuple.into_any()).unwrap(), + Value::Array(vec![ + Value::String("a".to_owned()), + Value::String("b".to_owned()) + ]) + ); + }); + } + + #[test] + fn python_to_json_converts_dicts_with_string_keys() { + Python::attach(|py| { + let dict = PyDict::new(py); + dict.set_item("role", "subject").unwrap(); + dict.set_item("count", 3_i64).unwrap(); + let converted = python_to_json(&dict.into_any()).unwrap(); + assert_eq!( + converted, + serde_json::json!({ "role": "subject", "count": 3 }) + ); + }); + } + + #[test] + fn python_to_json_refuses_an_unsupported_type() { + Python::attach(|py| { + // A built-in function object is not one of the shapes this + // bridge understands. + let builtins = py.import("builtins").expect("builtins imports"); + let function = builtins.getattr("len").expect("len exists"); + assert!(python_to_json(&function).is_err()); + }); + } + + #[test] + fn json_to_python_round_trips_through_python_to_json() { + Python::attach(|py| { + let original = serde_json::json!({ + "a": 1, + "b": [true, false, null, "text", 2.5], + "c": { "nested": "value" }, + }); + let python_value = json_to_python(py, &original).expect("conversion succeeds"); + let round_tripped = python_to_json(&python_value).expect("conversion succeeds"); + assert_eq!(round_tripped, original); + }); + } + + #[test] + fn spec_from_json_accepts_a_valid_specification() { + let spec = spec_from_json(&valid_spec_json()).expect("the specification is valid"); + assert_eq!(spec.requirement, "urn:example:requirement:v1"); + assert_eq!(spec.subjects.len(), 1); + assert_eq!(spec.subjects[0].role, "subject"); + assert!(matches!( + spec.subject_expectations, + SubjectExpectations::AcceptFirstUse + )); + } + + #[test] + fn spec_from_json_refuses_a_missing_required_string_field() { + for field in [ + "requirement", + "purpose", + "audience", + "evidence_type", + "issued_by", + "provided_by", + "configuration_revision", + ] { + let mut spec = valid_spec_json(); + spec.as_object_mut().unwrap().remove(field); + assert!( + spec_from_json(&spec).is_err(), + "`{field}` should be required" + ); + } + } + + #[test] + fn spec_from_json_refuses_subjects_that_are_not_a_sequence() { + let mut spec = valid_spec_json(); + spec["subjects"] = Value::String("not a sequence".to_owned()); + assert!(spec_from_json(&spec).is_err()); + } + + #[test] + fn spec_from_json_refuses_an_invalid_assurance_profile() { + let mut spec = valid_spec_json(); + spec["expected_assurance_profile"] = Value::String("not-a-real-profile".to_owned()); + assert!(spec_from_json(&spec).is_err()); + } + + #[test] + fn spec_from_json_refuses_invalid_expected_outputs() { + let mut spec = valid_spec_json(); + spec["expected_outputs"] = serde_json::json!([{ "concept": "x", "form": "not-a-form" }]); + assert!(spec_from_json(&spec).is_err()); + } + + #[test] + fn subject_expectations_from_json_accepts_both_shapes() { + match subject_expectations_from_json(&Value::String("accept_first_use".to_owned())).unwrap() + { + SubjectExpectations::AcceptFirstUse => {} + SubjectExpectations::Pinned(_) => panic!("expected accept_first_use"), + } + + let pinned = subject_expectations_from_json(&serde_json::json!([ + { "role": "subject", "binding": "urn:evidence:subject:v1_AAAA" } + ])) + .unwrap(); + match pinned { + SubjectExpectations::Pinned(subjects) => { + assert_eq!(subjects.len(), 1); + assert_eq!(subjects[0].role, "subject"); + assert_eq!(subjects[0].binding, "urn:evidence:subject:v1_AAAA"); + } + SubjectExpectations::AcceptFirstUse => panic!("expected pinned subjects"), + } + } + + #[test] + fn subject_expectations_from_json_refuses_anything_else() { + for value in [ + Value::String("acceptFirstUse".to_owned()), + Value::String("AcceptFirstUse".to_owned()), + serde_json::json!({ "pinned": [] }), + Value::Null, + Value::Bool(true), + ] { + assert!( + subject_expectations_from_json(&value).is_err(), + "{value:?} should have been refused" + ); + } + } + + /// `SubjectExpectations` carries no `PartialEq` (it is a foreign type this + /// crate does not own), so the round trip is proven through its JSON + /// rendering, which does implement equality, rather than through the + /// Rust value directly. + #[test] + fn subject_expectations_round_trips_through_json() { + let accept_first_use_json = + subject_expectations_to_json(&SubjectExpectations::AcceptFirstUse); + let round_tripped = subject_expectations_from_json(&accept_first_use_json).unwrap(); + assert_eq!( + subject_expectations_to_json(&round_tripped), + accept_first_use_json + ); + + let pinned_json = subject_expectations_to_json(&SubjectExpectations::Pinned(vec![ + ExpectedSubjectDocument { + role: "subject".to_owned(), + binding: "urn:evidence:subject:v1_AAAA".to_owned(), + }, + ])); + let round_tripped = subject_expectations_from_json(&pinned_json).unwrap(); + assert_eq!(subject_expectations_to_json(&round_tripped), pinned_json); + } + + #[test] + fn config_from_parts_accepts_a_static_token() { + let config = config_from_parts( + "https://evidence.example/", + &serde_json::json!({ "keys": [] }), + &Value::String("a-static-token".to_owned()), + None, + None, + None, + None, + None, + ) + .expect("the configuration is well-shaped"); + assert_eq!(config.base_url().as_str(), "https://evidence.example/"); + } + + #[test] + fn config_from_parts_accepts_a_private_key_jwt_provider() { + let token = serde_json::json!({ + "private_key_jwt": { + "token_endpoint": "https://issuer.example/token", + "client_id": "test-client", + "client_key": generated_private_jwk_json(), + } + }); + config_from_parts( + "https://evidence.example/", + &serde_json::json!({ "keys": [] }), + &token, + Some(5.5), + Some(1.0), + Some("test-agent".to_owned()), + None, + Some(1024), + ) + .expect("the configuration is well-shaped"); + } + + #[test] + fn config_from_parts_refuses_a_malformed_base_url() { + let error = config_from_parts( + "not a url", + &serde_json::json!({ "keys": [] }), + &Value::String("token".to_owned()), + None, + None, + None, + None, + None, + ) + .unwrap_err(); + assert!(matches!(error, ConfigError::Shape(_))); + } + + #[test] + fn config_from_parts_refuses_a_token_object_with_the_wrong_shape() { + let error = config_from_parts( + "https://evidence.example/", + &serde_json::json!({ "keys": [] }), + &serde_json::json!({ "static": "token" }), + None, + None, + None, + None, + None, + ) + .unwrap_err(); + assert!(matches!(error, ConfigError::Shape(_))); + } + + #[test] + fn config_from_parts_refuses_a_malformed_client_key() { + let token = serde_json::json!({ + "private_key_jwt": { + "token_endpoint": "https://issuer.example/token", + "client_id": "test-client", + "client_key": { "kty": "not-a-real-key-type" }, + } + }); + let error = config_from_parts( + "https://evidence.example/", + &serde_json::json!({ "keys": [] }), + &token, + None, + None, + None, + None, + None, + ) + .unwrap_err(); + assert!(matches!(error, ConfigError::Shape(_))); + } + + #[test] + fn duration_from_seconds_refuses_negative_infinite_and_nan() { + for seconds in [-1.0, f64::NEG_INFINITY, f64::INFINITY, f64::NAN] { + assert!(duration_from_seconds(seconds, "`x`").is_err()); + } + assert_eq!( + duration_from_seconds(1.5, "`x`").unwrap(), + Duration::from_secs_f64(1.5) + ); + } + + #[test] + fn datetime_from_unix_seconds_refuses_non_finite_input() { + for seconds in [f64::NEG_INFINITY, f64::INFINITY, f64::NAN] { + assert!(datetime_from_unix_seconds(seconds).is_err()); + } + let parsed = datetime_from_unix_seconds(0.0).unwrap(); + assert_eq!(parsed.timestamp(), 0); + } + + /// The discriminant is what the Python exception's `kind` attribute + /// carries, so every one of the eight stable names must map to itself. + /// `EvidenceClientError` is `#[non_exhaustive]` at the enum level, which + /// only bears on match exhaustiveness (a wildcard arm is required + /// elsewhere in this module); it does not block constructing a + /// known variant by name, so every case below is built directly rather + /// than through the crate's own `pub(crate)` convenience constructors. + #[test] + fn map_client_error_reports_every_stable_kind() { + let cases: Vec<(EvidenceClientError, &str)> = vec![ + ( + EvidenceClientError::Configuration { reason: "unusable" }, + "configuration", + ), + (EvidenceClientError::Nonce(NonceError::Entropy), "nonce"), + (EvidenceClientError::Token(TokenError::Unavailable), "token"), + ( + EvidenceClientError::Transport { + kind: TransportKind::Connect, + }, + "transport", + ), + ( + EvidenceClientError::Denied { + status: 403, + code: "not_authorized".to_owned(), + operation: None, + retry_after_seconds: None, + }, + "denied", + ), + ( + EvidenceClientError::NotAvailable { operation: None }, + "not_available", + ), + ( + EvidenceClientError::Protocol { + status: 500, + code: None, + operation: None, + retry_after_seconds: None, + }, + "protocol", + ), + ( + EvidenceClientError::Verification(VerificationError::Signature), + "verification", + ), + ]; + for (error, expected_kind) in &cases { + let mapped = map_client_error(error); + assert_eq!(mapped.kind, *expected_kind); + assert_eq!(mapped.message, error.to_string()); + } + } + + #[test] + fn map_client_error_carries_the_denied_fields() { + let error = EvidenceClientError::Denied { + status: 429, + code: "rate_limited".to_owned(), + operation: Some("01JQ0QZ8YHZ0000000000000AB".to_owned()), + retry_after_seconds: Some(30), + }; + let mapped = map_client_error(&error); + assert_eq!(mapped.status, Some(429)); + assert_eq!(mapped.code.as_deref(), Some("rate_limited")); + assert_eq!( + mapped.operation.as_deref(), + Some("01JQ0QZ8YHZ0000000000000AB") + ); + assert_eq!(mapped.retry_after_seconds, Some(30)); + } + + #[test] + fn map_client_error_carries_the_transport_kind() { + let mapped = map_client_error(&EvidenceClientError::Transport { + kind: TransportKind::ResponseTooLarge, + }); + assert_eq!(mapped.transport_kind, Some("response_too_large")); + } + + #[test] + fn map_client_error_carries_the_token_kind_and_its_own_sub_fields() { + let mapped = map_client_error(&EvidenceClientError::Token(TokenError::Transport { + kind: TransportKind::Timeout, + })); + assert_eq!(mapped.token_kind, Some("transport")); + assert_eq!(mapped.transport_kind, Some("timeout")); + } + + /// `code` and `operation` are already bounded upstream + /// (`evidence_client_sdk::problem::is_contract_code` and + /// `sanitized_operation`), so this only proves the mapping does not + /// re-encode, truncate, or otherwise alter an already-bounded value. + #[test] + fn map_client_error_passes_already_bounded_code_and_operation_through_unchanged() { + let code = "a".repeat(64); + let operation = "B".repeat(64); + let mapped = map_client_error(&EvidenceClientError::Denied { + status: 401, + code: code.clone(), + operation: Some(operation.clone()), + retry_after_seconds: None, + }); + assert_eq!(mapped.code, Some(code)); + assert_eq!(mapped.operation, Some(operation)); + } + + #[test] + fn map_client_error_never_carries_response_bytes_or_a_selector_value() { + // `Verification(Payload)` is the closest a mapped failure comes to a + // response-shaped cause; its message must still be the fixed, + // uninformative sentence the verifier defines, not a report about the + // payload's own content. + let mapped = map_client_error(&EvidenceClientError::Verification( + VerificationError::Payload, + )); + assert_eq!( + mapped.message, + "the Evidence response failed verification: Evidence payload is malformed" + ); + assert!(!mapped.message.contains("subject")); + assert!(!mapped.message.contains("selector")); + } + + #[test] + fn map_conversion_error_reports_the_configuration_kind() { + let mapped = map_conversion_error(&ConversionError::new("a canary reason")); + assert_eq!(mapped.kind, "configuration"); + assert_eq!(mapped.message, "a canary reason"); + } + + #[test] + fn map_config_error_delegates_to_the_right_mapping() { + let shape = map_config_error(&ConfigError::Shape(ConversionError::new("bad shape"))); + assert_eq!(shape.kind, "configuration"); + + let client = map_config_error(&ConfigError::Client(EvidenceClientError::Token( + TokenError::Unavailable, + ))); + assert_eq!(client.kind, "token"); + assert_eq!(client.token_kind, Some("unavailable")); + } + + #[test] + fn evidence_to_json_serializes_the_verified_payload() { + use evidence_client_sdk::{ + Evidence, EvidenceObjectType, PublicValue, SubjectBinding, SupportedValue, + }; + + let evidence = Evidence { + schema: registry_evidence_verifier::EVIDENCE_SCHEMA_V1.to_owned(), + assurance_profile: AssuranceProfile::Local, + request_nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(), + id: "urn:example:evidence:convert-test".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_owned(), + is_conformant_to: "urn:example:evidence-type:v1".to_owned(), + issued_by: "urn:example:issuer".to_owned(), + provided_by: "urn:example:provider".to_owned(), + issued_at: "2026-08-01T00:00:00Z".to_owned(), + observed_at: "2026-08-01T00:00:00Z".to_owned(), + valid_until: "2026-08-01T00:05:00Z".to_owned(), + purpose: "example-purpose".to_owned(), + audience: "urn:example:audience".to_owned(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept:status-holds".to_owned(), + value: PublicValue::Boolean(true), + }], + }; + + let value = evidence_to_json(&evidence).expect("evidence serializes"); + assert_eq!(value["requestNonce"], Value::String(evidence.request_nonce)); + assert_eq!( + value["subjects"][0]["role"], + Value::String("subject".to_owned()) + ); + } +} diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs new file mode 100644 index 000000000..7067b8245 --- /dev/null +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -0,0 +1,563 @@ +//! Python binding for the Evidence relying-party client, via PyO3. +//! +//! This is the only file in the crate that defines the `pymodule` surface; +//! every conversion lives in [`convert`] as a plain, unit-testable Rust +//! function. The client is synchronous from Python's side: it owns a private +//! current-thread tokio runtime and blocks on it for every network call, +//! releasing the GIL for the duration so other Python threads keep running. + +use pyo3::exceptions::{PyException, PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +// The wrapped SDK crate is `registry-evidence-client`, but this crate's own +// `[lib] name` (the Python module name the spec requires) is also +// `registry_evidence_client`, so `Cargo.toml` depends on it under the local +// alias `evidence-client-sdk` rather than its own package name. See that +// alias's own comment in `Cargo.toml` for why: it is not just tidiness here, +// since an unaliased dependency also breaks every integration test under +// `tests/` (a hard `error[E0464]`, not a `use`-path ambiguity `::` could fix). +use evidence_client_sdk::EvidenceClient as RealEvidenceClient; +use evidence_client_sdk::PreparedEvidenceRequest as RealPreparedEvidenceRequest; +use evidence_client_sdk::RawEvidenceResponse as RealRawEvidenceResponse; +use evidence_client_sdk::VerifiedEvidence as RealVerifiedEvidence; + +mod convert; + +use convert::{ + config_from_parts, datetime_from_unix_seconds, evidence_to_json, json_to_python, + map_client_error, map_config_error, map_conversion_error, python_to_json, spec_from_json, + subject_expectations_to_json, MappedError, +}; + +// Every instance also carries a `kind` attribute, one of the eight stable +// strings `EvidenceClientError::kind` reports: "configuration", "nonce", +// "token", "transport", "denied", "not_available", "protocol", or +// "verification". Branch on `kind`, never on the rendered message, which +// this crate does not freeze. +// +// Where the failure has them, an instance also carries `status`, `code`, +// `operation`, `retry_after_seconds`, `transport_kind` (set only for a +// "transport" failure), and `token_kind` (set only for a "token" failure). +// A "protocol" failure with `status` 401, 403, or 429 is reachable: it means +// the deployment answered outside its own contract (an uncoded refusal, or a +// response this client could not parse) rather than with a contract-coded +// problem response, which is "denied" instead. +// +// No attribute here ever carries response bytes, a credential, a header +// value, a selector value, or a subject binding. +// +// The doc string passed to each `create_exception!` call below (its fourth +// argument) is what Python sees as `__doc__`; a plain `///` comment placed +// before the macro invocation would not reach the generated type. +pyo3::create_exception!( + registry_evidence_client, + EvidenceClientError, + PyException, + "Base exception for every failure this client reports. See the module \ + documentation for the attributes every instance carries." +); + +pyo3::create_exception!( + registry_evidence_client, + ConfigurationError, + EvidenceClientError, + "The client cannot be used as configured, or a prepared request already \ + spent the single send it allows." +); + +pyo3::create_exception!( + registry_evidence_client, + NonceError, + EvidenceClientError, + "The request nonce could not be generated." +); + +pyo3::create_exception!( + registry_evidence_client, + TokenError, + EvidenceClientError, + "The credential presented to the deployment could not be obtained. See \ + the `token_kind` attribute for the specific cause." +); + +pyo3::create_exception!( + registry_evidence_client, + TransportError, + EvidenceClientError, + "The exchange with the deployment failed below the HTTP layer. See the \ + `transport_kind` attribute for the specific cause." +); + +pyo3::create_exception!( + registry_evidence_client, + DeniedError, + EvidenceClientError, + "The deployment refused the request with a contract-coded problem \ + response. See the `status`, `code`, and `retry_after_seconds` \ + attributes." +); + +pyo3::create_exception!( + registry_evidence_client, + NotAvailableError, + EvidenceClientError, + "The deployment answered that no evidence is available for this request." +); + +pyo3::create_exception!( + registry_evidence_client, + ProtocolError, + EvidenceClientError, + "The deployment answered outside its contract: an uncoded refusal, or a \ + response this client could not parse. See the `status` attribute." +); + +pyo3::create_exception!( + registry_evidence_client, + VerificationError, + EvidenceClientError, + "A signed response failed offline verification against the closed \ + policy. See the `code` attribute for the verifier's own kind." +); + +/// Build the Python exception matching one of the eight stable kinds. Every +/// kind [`evidence_client_sdk::EvidenceClientError::kind`] can report is +/// listed; a [`MappedError`] can only carry a ninth if the wrapped crate's +/// error enum (`#[non_exhaustive]`) grows one this crate does not yet know +/// about, in which case the base class still reports it faithfully. +fn exception_for_kind(kind: &str, message: String) -> PyErr { + match kind { + "configuration" => ConfigurationError::new_err(message), + "nonce" => NonceError::new_err(message), + "token" => TokenError::new_err(message), + "transport" => TransportError::new_err(message), + "denied" => DeniedError::new_err(message), + "not_available" => NotAvailableError::new_err(message), + "protocol" => ProtocolError::new_err(message), + "verification" => VerificationError::new_err(message), + _ => EvidenceClientError::new_err(message), + } +} + +/// Turn a mapped failure into the matching Python exception, with every field +/// [`MappedError`] carries attached as a plain attribute. `message` is always +/// `Display` text over the source failure; none of these attributes is a JSON +/// envelope. +fn to_py_err(py: Python<'_>, mapped: &MappedError) -> PyErr { + let error = exception_for_kind(mapped.kind, mapped.message.clone()); + let instance = error.value(py); + macro_rules! set_attr { + ($name:literal, $value:expr) => { + instance + .setattr($name, $value) + .expect("setting an attribute on a freshly constructed exception cannot fail") + }; + } + set_attr!("kind", mapped.kind); + set_attr!("status", mapped.status); + set_attr!("code", mapped.code.as_deref()); + set_attr!("operation", mapped.operation.as_deref()); + set_attr!("retry_after_seconds", mapped.retry_after_seconds); + set_attr!("transport_kind", mapped.transport_kind); + set_attr!("token_kind", mapped.token_kind); + error +} + +/// A serialization failure on a value this crate itself constructed (a +/// policy document, a definitions document, a key set, a verified payload) is +/// not a caller mistake: it has no `kind` among the eight stable ones, so it +/// is reported as a plain `ValueError` rather than forced into that envelope. +fn serialization_error(what: &str, error: serde_json::Error) -> PyErr { + PyValueError::new_err(format!("{what} could not be described: {error}")) +} + +/// One request, closed and nonce-bearing, before any byte has left the +/// process. +/// +/// There is no constructor exposed to Python: the only way to obtain one is +/// [`EvidenceClient::prepare`], mirroring the wrapped Rust type having no +/// public constructor either. It owns the real value directly rather than a +/// clone of it: the real type is deliberately not `Clone`, to protect its +/// interior single-send flag, and copying it here would defeat that guard. +#[pyclass(name = "PreparedEvidenceRequest")] +struct PreparedEvidenceRequest { + inner: RealPreparedEvidenceRequest, +} + +#[pymethods] +impl PreparedEvidenceRequest { + /// The nonce this request carries. Retain it with the transaction record: + /// re-verifying the stored response later needs the nonce from the + /// request, not from the response. + #[getter] + fn request_nonce(&self) -> &str { + self.inner.request_nonce() + } + + /// The closed verification policy, with the subject set as `prepare` left + /// it. + #[getter] + fn policy_document(&self, py: Python<'_>) -> PyResult> { + let value = serde_json::to_value(self.inner.policy_document()) + .map_err(|error| serialization_error("the policy document", error))?; + Ok(json_to_python(py, &value)?.unbind()) + } + + /// The subject expectations this request closed with: either the literal + /// string `"accept_first_use"`, or the sequence of `{"role", "binding"}` + /// mappings that were pinned. + #[getter] + fn subject_expectations(&self, py: Python<'_>) -> PyResult> { + let value = subject_expectations_to_json(self.inner.subject_expectations()); + Ok(json_to_python(py, &value)?.unbind()) + } +} + +/// A signed response, read but not yet judged. +/// +/// There is no constructor exposed to Python; the only way to obtain one is +/// [`EvidenceClient::send`]. It carries no attributes: nothing in it has been +/// trusted yet, and `verify` is what judges it, never Python code inspecting +/// its bytes directly. +#[pyclass(name = "RawEvidenceResponse")] +struct RawEvidenceResponse { + inner: RealRawEvidenceResponse, +} + +/// A response that satisfied every expectation. +/// +/// Unlike the two classes above, this is a terminal result nothing hands back +/// into a later call, so it carries plain, eagerly converted data rather than +/// protecting any interior state. +#[pyclass(name = "VerifiedEvidence")] +struct VerifiedEvidence { + /// The verified payload, as a plain Python object graph. + #[pyo3(get)] + evidence: Py, + /// The deployment's opaque identifier for the exchange that produced this + /// payload, for support correlation. + #[pyo3(get)] + operation: Option, + /// The role-bound subject bindings this payload carries, as pinned + /// expectations for a later request. Persist these after a first-use + /// acceptance and pass them as `subject_expectations` from then on. + #[pyo3(get)] + pinned_subject_expectations: Py, +} + +/// Convert a wrapped [`RealVerifiedEvidence`] into its Python-facing shape. +fn verified_evidence_to_python( + py: Python<'_>, + verified: &RealVerifiedEvidence, +) -> PyResult { + let evidence_value = evidence_to_json(verified.evidence()) + .map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; + let evidence = json_to_python(py, &evidence_value)?.unbind(); + let pinned_value = serde_json::to_value(verified.pinned_subject_expectations()) + .map_err(|error| serialization_error("the pinned subject expectations", error))?; + let pinned_subject_expectations = json_to_python(py, &pinned_value)?.unbind(); + Ok(VerifiedEvidence { + evidence, + operation: verified.operation().map(str::to_owned), + pinned_subject_expectations, + }) +} + +/// A relying party's connection to one Evidence deployment. +/// +/// The client owns a private, current-thread tokio runtime and blocks on it +/// for every asynchronous method, releasing the GIL for the duration so other +/// Python threads keep running. A current-thread runtime supports being +/// entered concurrently from more than one native thread: a second caller +/// waits for the first to yield the runtime's single core rather than racing +/// it, so two Python threads may safely call an async method on the same +/// client at once. +#[pyclass(name = "EvidenceClient")] +struct EvidenceClient { + inner: RealEvidenceClient, + runtime: tokio::runtime::Runtime, +} + +#[pymethods] +impl EvidenceClient { + /// Build a client for one deployment. + /// + /// `trusted_jwks` is mandatory: an empty key set is refused, exactly as + /// the wrapped Rust configuration refuses it. `token` is either a static + /// bearer string or the private-key-JWT provider's own settings; there is + /// no caller-supplied token provider in this binding. + #[new] + #[pyo3(signature = ( + base_url, + trusted_jwks, + token, + request_timeout_seconds=None, + connect_timeout_seconds=None, + user_agent=None, + trusted_root_certificates=None, + max_response_bytes=None, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + base_url: &str, + trusted_jwks: &Bound<'_, PyAny>, + token: &Bound<'_, PyAny>, + request_timeout_seconds: Option, + connect_timeout_seconds: Option, + user_agent: Option, + trusted_root_certificates: Option>, + max_response_bytes: Option, + ) -> PyResult { + let trusted_jwks_json = python_to_json(trusted_jwks) + .map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; + let token_json = + python_to_json(token).map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; + let config = config_from_parts( + base_url, + &trusted_jwks_json, + &token_json, + request_timeout_seconds, + connect_timeout_seconds, + user_agent, + trusted_root_certificates, + max_response_bytes, + ) + .map_err(|error| to_py_err(py, &map_config_error(&error)))?; + let inner = RealEvidenceClient::new(config) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + PyRuntimeError::new_err(format!( + "the client's internal runtime could not start: {error}" + )) + })?; + Ok(Self { inner, runtime }) + } + + /// Close the expectations for one request and generate its nonce. + /// + /// No I/O happens here, and this call is synchronous. The returned + /// request is good for exactly one exchange: spend it with `send` or + /// `request_and_verify`. + fn prepare( + &self, + py: Python<'_>, + spec: &Bound<'_, PyAny>, + ) -> PyResult { + let spec_json = + python_to_json(spec).map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; + let spec = spec_from_json(&spec_json) + .map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; + let prepared = self + .inner + .prepare(spec) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + Ok(PreparedEvidenceRequest { inner: prepared }) + } + + /// Read the request shapes this requester is entitled to send. + /// + /// Discovery is authoring input, not a trust anchor: it never supplies + /// verification expectations for a request already in flight. + fn discover(&self, py: Python<'_>) -> PyResult> { + let document = py + .detach(|| self.runtime.block_on(self.inner.discover())) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + let value = serde_json::to_value(&document) + .map_err(|error| serialization_error("the definitions document", error))?; + Ok(json_to_python(py, &value)?.unbind()) + } + + /// Read the deployment's published verification key set, for an + /// out-of-band pinning workflow. Verification never calls this: a key set + /// fetched from the same origin as the response it would verify + /// establishes nothing. + fn fetch_jwks(&self, py: Python<'_>) -> PyResult> { + let document = py + .detach(|| self.runtime.block_on(self.inner.fetch_jwks())) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + let value = serde_json::to_value(&document) + .map_err(|error| serialization_error("the key set", error))?; + Ok(json_to_python(py, &value)?.unbind()) + } + + /// Send one prepared request and read the signed response. + /// + /// `prepared` allows exactly one send: a second call with the same object + /// rejects with the configuration failure the wrapped client already + /// produces, without reaching the deployment. Retrying means preparing + /// again, for a fresh nonce. + fn send( + &self, + py: Python<'_>, + prepared: &PreparedEvidenceRequest, + ) -> PyResult { + let response = py + .detach(|| self.runtime.block_on(self.inner.send(&prepared.inner))) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + Ok(RawEvidenceResponse { inner: response }) + } + + /// Verify a signed response against the policy its request closed, as of + /// now. The trusted key set is the one pinned at construction, always. + /// + /// Unlike sending, verifying is unrestricted: it is offline, synchronous, + /// and idempotent, so a retained response may be re-verified against a + /// retained prepared request as often as needed, including after the + /// single send has been spent. + fn verify( + &self, + py: Python<'_>, + prepared: &PreparedEvidenceRequest, + response: &RawEvidenceResponse, + ) -> PyResult { + let verified = self + .inner + .verify(&prepared.inner, &response.inner) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + verified_evidence_to_python(py, &verified) + } + + /// Request evidence and verify it in one step. This spends the single + /// send `prepared` allows, exactly as `send` does, so calling it twice + /// with one prepared request fails locally on the second call. + fn request_and_verify( + &self, + py: Python<'_>, + prepared: &PreparedEvidenceRequest, + ) -> PyResult { + let verified = py + .detach(|| { + self.runtime + .block_on(self.inner.request_and_verify(&prepared.inner)) + }) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + verified_evidence_to_python(py, &verified) + } + + /// Verify a retained response as of an explicit instant, given as seconds + /// since the UNIX epoch (the same value `datetime.timestamp()` yields). + /// + /// `verify` judges a response against the current clock, which is right + /// when the response has just arrived. This variant names the instant + /// instead, for re-verifying a retained response or replaying a retained + /// transaction record at the instant the original decision was made. + /// + /// A past instant is the direction that costs something: naming a stale + /// instant accepts an assertion whose validity interval has since + /// elapsed, because the question asked is whether it was acceptable + /// then, and the answer stays yes forever. A live trust decision calls + /// `verify`, not this. + fn verify_as_of( + &self, + py: Python<'_>, + prepared: &PreparedEvidenceRequest, + response: &RawEvidenceResponse, + as_of_unix_seconds: f64, + ) -> PyResult { + let now = datetime_from_unix_seconds(as_of_unix_seconds) + .map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; + let verified = self + .inner + .verify_as_of(&prepared.inner, &response.inner, now) + .map_err(|error| to_py_err(py, &map_client_error(&error)))?; + verified_evidence_to_python(py, &verified) + } +} + +// `pub` so `tests/happy_path.rs` (a separate integration-test crate) can call +// this directly rather than going through a real `import`: building a +// `PyModule` and handing it to this function is the same registration path +// Python's own import machinery would drive, so a direct call still exercises +// genuine `#[pyclass]`/`#[pymethods]` dispatch and argument marshaling, with +// none of the sequencing that `pyo3::append_to_inittab!` would need against +// this crate's `auto-initialize` dev-dependency (used by the panic-boundary +// test below). +#[pymodule] +pub fn registry_evidence_client(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + let py = module.py(); + module.add("EvidenceClientError", py.get_type::())?; + module.add("ConfigurationError", py.get_type::())?; + module.add("NonceError", py.get_type::())?; + module.add("TokenError", py.get_type::())?; + module.add("TransportError", py.get_type::())?; + module.add("DeniedError", py.get_type::())?; + module.add("NotAvailableError", py.get_type::())?; + module.add("ProtocolError", py.get_type::())?; + module.add("VerificationError", py.get_type::())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// PyO3 wraps every generated `pymethods`/`pyfunction` entry point in + /// `std::panic::catch_unwind`, translating an unwind into a Python + /// `PanicException` rather than letting it cross the FFI boundary + /// (undefined behavior). Confirmed by reading the vendored source: + /// `pyo3-0.29.1/src/impl_/trampoline.rs` lines 289-324 (`catch_unwind` at + /// line 301, `PanicException::from_panic_payload` at line 320) and + /// `pyo3-0.29.1/src/panic.rs` (`PanicException` itself, at + /// `pyo3::panic::PanicException`, not re-exported at the crate root). + /// + /// This crate has exactly one latent panic to worry about: + /// `Ulid::new()` (used to generate a request nonce) reaches + /// `rand::rng()`, which panics when OS entropy is unavailable, a case the + /// wrapped crate deliberately leaves unguarded. No extra guard is added + /// here for it: this test proves the boundary already turns any such + /// panic into an ordinary Python exception instead of a process abort. + /// + /// The catch must be exercised from Python code, not from a Rust `call0`: + /// `PyErr::take` (which every pyo3 call-from-Rust helper uses to read the + /// interpreter's error back into Rust) deliberately resumes the original + /// panic the moment Rust re-observes a `PanicException`, exactly so a + /// caught panic can never be silently absorbed into ordinary Rust error + /// handling. That resuming is itself confirmation that pyo3 treats a + /// caught panic specially; it is not this test's subject. A genuine + /// Python caller never triggers it, since plain Python `except` clauses + /// clear the interpreter's error state directly rather than through that + /// Rust-side path, so this test drives the call the same way: through a + /// `try`/`except` block executed as Python code. + #[test] + fn panics_cross_the_boundary_as_a_python_exception() { + #[pyfunction] + fn panic_for_test() { + panic!("deliberate panic for the trampoline boundary test"); + } + + Python::attach(|py| { + let function = wrap_pyfunction!(panic_for_test, py).expect("function wraps"); + let locals = pyo3::types::PyDict::new(py); + locals + .set_item("panic_for_test", function) + .expect("locals accept the function"); + py.run( + c"try: + panic_for_test() + caught = None +except BaseException as error: + caught = type(error).__name__", + None, + Some(&locals), + ) + .expect("the script itself must not raise: the panic is caught inside it"); + let caught: String = locals + .get_item("caught") + .expect("locals lookup succeeds") + .expect("`caught` was assigned") + .extract() + .expect("`caught` is a string"); + assert_eq!(caught, "PanicException"); + }); + } +} diff --git a/crates/registry-evidence-client-py/tests/fixtures/jwks.json b/crates/registry-evidence-client-py/tests/fixtures/jwks.json new file mode 100644 index 000000000..ebadc4173 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/fixtures/jwks.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "alg": "EdDSA", + "crv": "Ed25519", + "kid": "evidence-python-fixture-key-1", + "kty": "OKP", + "x": "TNEiCl1Dmh2rsbO3-TDo_dTjwID04eQq079z-tEPEHM" + } + ] +} diff --git a/crates/registry-evidence-client-py/tests/fixtures/policy.json b/crates/registry-evidence-client-py/tests/fixtures/policy.json new file mode 100644 index 000000000..c16615da0 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/fixtures/policy.json @@ -0,0 +1,25 @@ +{ + "expectedAssuranceProfile": "local", + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "requirement": "urn:example:requirement:v1", + "evidenceType": "urn:example:evidence-type:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "configurationRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "requestNonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expectedSubjects": [ + { + "role": "subject", + "binding": "urn:evidence:subject:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "expectedOutputs": [ + { + "concept": "urn:example:concept:status-holds", + "form": "boolean" + } + ], + "maximumAssertionLifetimeSeconds": 315360000, + "clockSkewSeconds": 30 +} diff --git a/crates/registry-evidence-client-py/tests/fixtures/response.jws.json b/crates/registry-evidence-client-py/tests/fixtures/response.jws.json new file mode 100644 index 000000000..8bda6da98 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/fixtures/response.jws.json @@ -0,0 +1,5 @@ +{ + "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2aWRlbmNlLXB5dGhvbi1maXh0dXJlLWtleS0xIiwidHlwIjoiZXZpZGVuY2UrandzIiwiY3R5IjoiYXBwbGljYXRpb24vZXZpZGVuY2UranNvbiJ9", + "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpweXRob24tZml4dHVyZSIsInR5cGUiOiJFdmlkZW5jZSIsInN1cHBvcnRzUmVxdWlyZW1lbnQiOiJ1cm46ZXhhbXBsZTpyZXF1aXJlbWVudDp2MSIsImlzQ29uZm9ybWFudFRvIjoidXJuOmV4YW1wbGU6ZXZpZGVuY2UtdHlwZTp2MSIsImlzc3VlZEJ5IjoidXJuOmV4YW1wbGU6aXNzdWVyIiwicHJvdmlkZWRCeSI6InVybjpleGFtcGxlOnByb3ZpZGVyIiwiaXNzdWVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsIm9ic2VydmVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsInZhbGlkVW50aWwiOiIyMDM2LTA3LTI5VDAwOjAwOjAwWiIsInB1cnBvc2UiOiJleGFtcGxlLXB1cnBvc2UiLCJhdWRpZW5jZSI6InVybjpleGFtcGxlOmF1ZGllbmNlIiwiY29uZmlndXJhdGlvblJldmlzaW9uIjoic2hhMjU2OjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJzdWJqZWN0cyI6W3sicm9sZSI6InN1YmplY3QiLCJiaW5kaW5nIjoidXJuOmV2aWRlbmNlOnN1YmplY3Q6djFfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9XSwic3VwcG9ydGVkVmFsdWVzIjpbeyJwcm92aWRlc1ZhbHVlRm9yIjoidXJuOmV4YW1wbGU6Y29uY2VwdDpzdGF0dXMtaG9sZHMiLCJ2YWx1ZSI6dHJ1ZX1dfQ", + "signature": "KM0xZJlpB728jz3qpFSy0J4Q8D9-YQkbwVn6j873DEaGwohADoviqRvPRUoHNVApOxC3XFAYaNZjsfgxUgs-DA" +} diff --git a/crates/registry-evidence-client-py/tests/golden_fixture.rs b/crates/registry-evidence-client-py/tests/golden_fixture.rs new file mode 100644 index 000000000..d35a94168 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/golden_fixture.rs @@ -0,0 +1,228 @@ +//! Golden fixture for the Python suite's `discover`/`fetch_jwks` stubs and for +//! a direct Rust-side check that a stored response still verifies. +//! +//! A Python test cannot sign an Evidence response itself: the crates that can +//! (`registry-evidence-verifier` and `registry-evidence-client`) keep their +//! test signers `#[cfg(test)]`-private, so this file builds one directly with +//! `registry-platform-crypto`, the same way those crates' own tests do, and +//! the same way `crates/registry-evidence-client-node/tests/golden_fixture.rs` +//! does for the Node suite. The two fixture generators are deliberately +//! duplicated rather than shared: factoring the pattern out into a common +//! test-support crate is a real option, but a decision this task did not ask +//! to make. +//! +//! Regenerate with: +//! ```text +//! cargo test -p registry-evidence-client-py --test golden_fixture -- --ignored regenerate_golden_fixture +//! ``` +//! The signing key is generated fresh every run and discarded; only its +//! public half is committed, inside `tests/fixtures/jwks.json`. + +use std::{fs, path::Path}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use ed25519_dalek::SigningKey; +// This file is a separate integration-test crate, and Cargo auto-links two +// different crates under the identical name `registry_evidence_client` here: +// this package's own compiled library (its `[lib] name`) and the wrapped SDK +// crate (`registry-evidence-client`, a `[dependencies]` entry with no custom +// lib name of its own). Both entries genuinely belong to the extern prelude +// under the same string, so unlike `src/lib.rs` (where the leading `::` picks +// between a same-named crate-root item and a single extern crate), a leading +// `::` alone cannot disambiguate two same-named extern crates. The SDK crate +// is instead pulled in under its own alias, `evidence_client_sdk` (see this +// crate's `Cargo.toml`), so every reference below is unambiguous by +// construction. +use evidence_client_sdk::{ + AssuranceProfile, Evidence, EvidenceObjectType, EvidenceVerificationPolicyDocument, + ExpectedFormDocument, ExpectedOutputDocument, ExpectedScalarFormDocument, + ExpectedSubjectDocument, JwksDocument, PublicValue, SubjectBinding, SupportedValue, +}; +use registry_evidence_verifier::{ + model::FlattenedJws, verifier::verify_flattened_jws, EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, + EVIDENCE_SCHEMA_V1, +}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + +/// Canonical all-zero nonce for offline fixture evaluation, matching the +/// convention `registry-evidence-verifier`'s own fixtures use. A real request +/// always carries a freshly generated nonce; this fixture never goes through +/// `prepare`, so there is nothing independent to match it against. +const FIXTURE_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const ACTIVE_KEY_ID: &str = "evidence-python-fixture-key-1"; + +/// Ten years past `issued_at`. The Python suite runs this fixture through +/// `discover`/`fetch_jwks` stubs indefinitely into the future, so its +/// validity window has to outlive ordinary gaps between regenerations, not +/// just the day it was generated. +const FIXTURE_LIFETIME_DAYS: i64 = 3650; + +fn fixtures_dir() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) +} + +fn fixture_evidence(issued_at: DateTime, valid_until: DateTime) -> Evidence { + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_owned(), + assurance_profile: AssuranceProfile::Local, + request_nonce: FIXTURE_NONCE.to_owned(), + id: "urn:example:evidence:python-fixture".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_owned(), + is_conformant_to: "urn:example:evidence-type:v1".to_owned(), + issued_by: "urn:example:issuer".to_owned(), + provided_by: "urn:example:provider".to_owned(), + issued_at: issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + observed_at: issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + valid_until: valid_until.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + purpose: "example-purpose".to_owned(), + audience: "urn:example:audience".to_owned(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept:status-holds".to_owned(), + value: PublicValue::Boolean(true), + }], + } +} + +fn fixture_policy_document(evidence: &Evidence) -> EvidenceVerificationPolicyDocument { + EvidenceVerificationPolicyDocument { + expected_assurance_profile: evidence.assurance_profile, + issued_by: evidence.issued_by.clone(), + provided_by: evidence.provided_by.clone(), + requirement: evidence.supports_requirement.clone(), + evidence_type: evidence.is_conformant_to.clone(), + purpose: evidence.purpose.clone(), + audience: evidence.audience.clone(), + configuration_revision: evidence.configuration_revision.clone(), + request_nonce: evidence.request_nonce.clone(), + expected_subjects: evidence + .subjects + .iter() + .map(|subject| ExpectedSubjectDocument { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect(), + expected_outputs: evidence + .supported_values + .iter() + .map(|value| ExpectedOutputDocument { + concept: value.provides_value_for.clone(), + form: ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean), + }) + .collect(), + maximum_assertion_lifetime_seconds: (FIXTURE_LIFETIME_DAYS * 24 * 60 * 60) as u64, + clock_skew_seconds: 30, + } +} + +#[derive(serde::Serialize)] +struct ProtectedHeader<'a> { + alg: &'static str, + kid: &'a str, + typ: &'static str, + cty: &'static str, +} + +async fn sign(evidence: &Evidence, signer: &LocalJwkSigner) -> FlattenedJws { + let payload = serde_json::to_vec(evidence).expect("evidence serializes"); + let protected = serde_json::to_vec(&ProtectedHeader { + alg: "EdDSA", + kid: signer.key_id(), + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }) + .expect("protected header serializes"); + + let protected = URL_SAFE_NO_PAD.encode(protected); + let payload = URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{protected}.{payload}"); + let signature = signer + .sign(signing_input.as_bytes()) + .await + .expect("the fixture key signs"); + + FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + } +} + +fn write_pretty(path: &Path, value: &T) { + let mut json = serde_json::to_string_pretty(value).expect("the fixture serializes"); + json.push('\n'); + fs::write(path, json).unwrap_or_else(|error| panic!("writing {path:?} failed: {error}")); +} + +/// Rewrites the three committed fixture files from a freshly generated key, +/// used once and discarded here. Not run by the ordinary suite; see the +/// module doc comment for the exact command. +#[tokio::test] +#[ignore] +async fn regenerate_golden_fixture() { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the host supplies randomness"); + let signing_key = SigningKey::from_bytes(&seed); + let private_jwk_json = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": ACTIVE_KEY_ID, + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + }); + let private_jwk = + PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); + let signer = LocalJwkSigner::new(private_jwk).expect("the generated key signs"); + + let issued_at: DateTime = "2026-08-01T00:00:00Z".parse().expect("issued_at parses"); + let valid_until = issued_at + ChronoDuration::days(FIXTURE_LIFETIME_DAYS); + let evidence = fixture_evidence(issued_at, valid_until); + let policy_document = fixture_policy_document(&evidence); + let jws = sign(&evidence, &signer).await; + let jwks = JwksDocument { + keys: vec![serde_json::to_value(signer.public_jwk()).expect("the public key serializes")], + }; + + let dir = fixtures_dir(); + fs::create_dir_all(dir).expect("the fixtures directory can be created"); + write_pretty(&dir.join("response.jws.json"), &jws); + write_pretty(&dir.join("jwks.json"), &jwks); + write_pretty(&dir.join("policy.json"), &policy_document); +} + +/// Confirms the committed fixture still verifies against the real wall clock, +/// so the Python suite can trust `jwks.json` and `response.jws.json` without +/// re-deriving them. +#[test] +fn golden_fixture_verifies_against_the_real_clock() { + let dir = fixtures_dir(); + let jws_bytes = fs::read(dir.join("response.jws.json")).expect("the response fixture exists"); + let jwks: JwksDocument = + serde_json::from_slice(&fs::read(dir.join("jwks.json")).expect("the JWKS fixture exists")) + .expect("the JWKS fixture parses"); + let policy_document: EvidenceVerificationPolicyDocument = serde_json::from_slice( + &fs::read(dir.join("policy.json")).expect("the policy fixture exists"), + ) + .expect("the policy fixture parses"); + + let policy = policy_document.into_policy(Utc::now()); + let evidence = verify_flattened_jws(&jws_bytes, &jwks, &policy).expect("the fixture verifies"); + + assert_eq!(evidence.request_nonce, FIXTURE_NONCE); + assert_eq!(evidence.subjects.len(), 1); + assert_eq!(evidence.subjects[0].role, "subject"); + assert_eq!(evidence.supported_values.len(), 1); + assert!(matches!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + )); +} diff --git a/crates/registry-evidence-client-py/tests/happy_path.rs b/crates/registry-evidence-client-py/tests/happy_path.rs new file mode 100644 index 000000000..a42729cb2 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/happy_path.rs @@ -0,0 +1,475 @@ +//! Live round trip through the real Python-facing surface. +//! +//! Every call here goes through genuine PyO3 dispatch: a `PyModule` is built +//! directly from this crate's own `#[pymodule]` entry point (the same +//! registration a real `import registry_evidence_client` performs), then +//! every object is built and driven with `getattr`/`call1`/`call_method1`, +//! exactly as a Python caller would. Nothing here reaches into a pyclass's +//! Rust fields directly, and `pyo3::append_to_inittab!` is deliberately not +//! used: its own doc comment asks callers to leave the `auto-initialize` +//! feature off, which this crate's `[dev-dependencies]` already turns on for +//! the panic-boundary unit test in `src/lib.rs`. Building the module directly +//! exercises the same `#[pyclass]`/`#[pymethods]` marshaling without needing +//! that sequencing at all. +//! +//! `prepare()` mints a fresh nonce on every call with no injection seam, so +//! the stub deployment can only sign its answer once the live nonce is +//! known: every test here calls `prepare` first (synchronous, no network), +//! reads the resulting nonce back through the real `request_nonce` getter, +//! signs a matching response, and only then mounts it on the stub. This +//! mirrors `crates/registry-evidence-client-node/__test__/happy-path.test.js`, +//! which does the same thing for the Node binding. +//! +//! The client's own internal tokio runtime is a second, independent +//! `tokio::runtime::Runtime` the pyclass builds and blocks on for every +//! network call (see `EvidenceClient::new` in `src/lib.rs`). Blocking on it +//! while already inside another runtime's `block_on` frame on the same +//! thread panics ("Cannot start a runtime from within a runtime"), so every +//! test here drives the stub server's own async setup (starting the server, +//! mounting a responder) through a `block_on` call that always returns +//! before any Python method call happens. By the time `EvidenceClient::send` +//! builds and blocks on its own runtime, this thread is not inside anyone +//! else's `block_on` frame. + +use std::{fs, path::Path}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{Duration as ChronoDuration, Utc}; +use ed25519_dalek::{Signer, SigningKey}; +use evidence_client_sdk::{ + AssuranceProfile, Evidence, EvidenceObjectType, JwksDocument, PublicValue, SubjectBinding, + SupportedValue, +}; +use pyo3::prelude::*; +use registry_evidence_verifier::{ + EVIDENCE_JWS_CTY, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, +}; +use wiremock::{ + matchers::{method, path as path_matcher}, + Mock, MockServer, ResponseTemplate, +}; + +const KEY_ID: &str = "evidence-python-live-key-1"; + +/// The specification every send/verify test in this file prepares against, +/// as the plain Python-facing (snake_case) shape `spec_from_json` expects. +/// `subject_expectations` is `"accept_first_use"`, so verification pins +/// whatever binding the response asserts rather than checking it against a +/// value chosen ahead of time. +fn request_spec_json() -> serde_json::Value { + serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "evidence_type": "urn:example:evidence-type:v1", + "issued_by": "urn:example:issuer", + "provided_by": "urn:example:provider", + "configuration_revision": format!("sha256:{}", "0".repeat(64)), + "expected_assurance_profile": "local", + "subjects": [ + { "role": "subject", "selector_profile": "national-id" } + ], + "expected_outputs": [ + { "concept": "urn:example:concept:status-holds", "form": "boolean" } + ], + "maximum_assertion_lifetime_seconds": 300, + "clock_skew_seconds": 60, + "subject_expectations": "accept_first_use", + }) +} + +/// A fresh Ed25519 key, generated and discarded within one test. Distinct +/// from the golden fixture's committed key: these tests need to sign a +/// response for a nonce that does not exist until `prepare()` runs, so they +/// cannot use a response signed ahead of time. +fn fresh_signing_key() -> SigningKey { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the host supplies randomness"); + SigningKey::from_bytes(&seed) +} + +fn trusted_jwks_json(signing_key: &SigningKey) -> serde_json::Value { + let jwks = JwksDocument { + keys: vec![serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": KEY_ID, + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + })], + }; + serde_json::to_value(jwks).expect("the key set serializes") +} + +/// Build the `Evidence` payload matching [`request_spec_json`], for the +/// given live nonce and subject binding. +fn evidence_for(request_nonce: &str, subject_binding: &str) -> Evidence { + let issued_at = Utc::now(); + let valid_until = issued_at + ChronoDuration::seconds(120); + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_owned(), + assurance_profile: AssuranceProfile::Local, + request_nonce: request_nonce.to_owned(), + id: "urn:example:evidence:python-live".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_owned(), + is_conformant_to: "urn:example:evidence-type:v1".to_owned(), + issued_by: "urn:example:issuer".to_owned(), + provided_by: "urn:example:provider".to_owned(), + issued_at: issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + observed_at: issued_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + valid_until: valid_until.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + purpose: "example-purpose".to_owned(), + audience: "urn:example:audience".to_owned(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: subject_binding.to_owned(), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept:status-holds".to_owned(), + value: PublicValue::Boolean(true), + }], + } +} + +#[derive(serde::Serialize)] +struct ProtectedHeader<'a> { + alg: &'static str, + kid: &'a str, + typ: &'static str, + cty: &'static str, +} + +#[derive(serde::Serialize)] +struct FlattenedJwsBody { + protected: String, + payload: String, + signature: String, +} + +/// Sign synchronously with the raw key, unlike the golden fixture's own +/// signer: mounting has to happen after `prepare()` names a nonce, and by +/// then this test is past the one async setup step it allows itself (see the +/// module doc comment), so the signature is computed with `ed25519_dalek` +/// directly rather than through the async `SigningProvider` trait. +fn sign(evidence: &Evidence, signing_key: &SigningKey) -> Vec { + let payload = serde_json::to_vec(evidence).expect("evidence serializes"); + let protected = serde_json::to_vec(&ProtectedHeader { + alg: "EdDSA", + kid: KEY_ID, + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }) + .expect("protected header serializes"); + + let protected = URL_SAFE_NO_PAD.encode(protected); + let payload = URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{protected}.{payload}"); + let signature = signing_key.sign(signing_input.as_bytes()); + + serde_json::to_vec(&FlattenedJwsBody { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()), + }) + .expect("the flattened JWS serializes") +} + +/// Parse `value` into a genuine Python object via the stdlib `json` module, +/// the same as a real caller loading a specification or a key set from a +/// file would. Kept independent of this crate's own `convert::json_to_python` +/// helper on purpose: that function lives in a private module, unreachable +/// from an integration test, and a real caller has no access to it either. +fn python_json<'py>(py: Python<'py>, value: &serde_json::Value) -> Bound<'py, PyAny> { + let text = serde_json::to_string(value).expect("the value serializes"); + py.import("json") + .expect("the json module is available") + .call_method1("loads", (text,)) + .expect("the value parses back") +} + +/// Build the extension module directly from its own `#[pymodule]` entry +/// point, the same registration a real `import registry_evidence_client` +/// performs. See the module doc comment for why this is used instead of +/// `pyo3::append_to_inittab!` plus an embedded interpreter. +fn evidence_client_module(py: Python<'_>) -> Bound<'_, PyModule> { + let module = PyModule::new(py, "registry_evidence_client").expect("the module object builds"); + registry_evidence_client::registry_evidence_client(&module) + .expect("the module registers its classes and exceptions"); + module +} + +#[test] +fn round_trip_through_send_and_verify() { + let signing_key = fresh_signing_key(); + let trusted_jwks = trusted_jwks_json(&signing_key); + let subject_binding = format!("urn:evidence:subject:v1_{}", "A".repeat(43)); + + let runtime = tokio::runtime::Runtime::new().expect("the stub's runtime starts"); + let server = runtime.block_on(MockServer::start()); + let base_url = server.uri(); + + Python::attach(|py| { + let module = evidence_client_module(py); + let client_class = module.getattr("EvidenceClient").expect("the class exists"); + let client = client_class + .call1(( + base_url.as_str(), + python_json(py, &trusted_jwks), + "test-token", + )) + .expect("the client is constructed"); + + let prepared = client + .call_method1("prepare", (python_json(py, &request_spec_json()),)) + .expect("the specification is accepted"); + let nonce: String = prepared + .getattr("request_nonce") + .expect("the nonce getter exists") + .extract() + .expect("the nonce is a string"); + + let body = sign(&evidence_for(&nonce, &subject_binding), &signing_key); + runtime.block_on( + Mock::given(method("POST")) + .and(path_matcher("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(body, EVIDENCE_JWS_MEDIA_TYPE), + ) + .expect(1) + .mount(&server), + ); + + let response = client + .call_method1("send", (&prepared,)) + .expect("the stub answers the one send this request allows"); + let verified = client + .call_method1("verify", (&prepared, &response)) + .expect("the response verifies"); + + let verified_evidence = verified.getattr("evidence").expect("evidence is exposed"); + let verified_nonce: String = verified_evidence + .get_item("requestNonce") + .expect("requestNonce is present") + .extract() + .expect("requestNonce is a string"); + assert_eq!(verified_nonce, nonce); + + let operation: Option = verified + .getattr("operation") + .expect("operation is exposed") + .extract() + .expect("operation is a string or None"); + assert_eq!(operation, None); + + let pinned = verified + .getattr("pinned_subject_expectations") + .expect("pinned_subject_expectations is exposed"); + assert_eq!(pinned.len().expect("it supports len()"), 1); + let first = pinned.get_item(0).expect("one entry exists"); + let role: String = first.get_item("role").unwrap().extract().unwrap(); + let binding: String = first.get_item("binding").unwrap().extract().unwrap(); + assert_eq!(role, "subject"); + assert_eq!(binding, subject_binding); + }); +} + +#[test] +fn request_and_verify_performs_the_same_round_trip() { + let signing_key = fresh_signing_key(); + let trusted_jwks = trusted_jwks_json(&signing_key); + let subject_binding = format!("urn:evidence:subject:v1_{}", "B".repeat(43)); + + let runtime = tokio::runtime::Runtime::new().expect("the stub's runtime starts"); + let server = runtime.block_on(MockServer::start()); + let base_url = server.uri(); + + Python::attach(|py| { + let module = evidence_client_module(py); + let client_class = module.getattr("EvidenceClient").expect("the class exists"); + let client = client_class + .call1(( + base_url.as_str(), + python_json(py, &trusted_jwks), + "test-token", + )) + .expect("the client is constructed"); + + let prepared = client + .call_method1("prepare", (python_json(py, &request_spec_json()),)) + .expect("the specification is accepted"); + let nonce: String = prepared + .getattr("request_nonce") + .expect("the nonce getter exists") + .extract() + .expect("the nonce is a string"); + + let body = sign(&evidence_for(&nonce, &subject_binding), &signing_key); + runtime.block_on( + Mock::given(method("POST")) + .and(path_matcher("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(body, EVIDENCE_JWS_MEDIA_TYPE), + ) + .expect(1) + .mount(&server), + ); + + let verified = client + .call_method1("request_and_verify", (&prepared,)) + .expect("the one-call round trip succeeds"); + + let verified_evidence = verified.getattr("evidence").expect("evidence is exposed"); + let verified_nonce: String = verified_evidence + .get_item("requestNonce") + .expect("requestNonce is present") + .extract() + .expect("requestNonce is a string"); + assert_eq!(verified_nonce, nonce); + }); +} + +#[test] +fn a_second_send_is_refused_without_reaching_the_deployment() { + let signing_key = fresh_signing_key(); + let trusted_jwks = trusted_jwks_json(&signing_key); + let subject_binding = format!("urn:evidence:subject:v1_{}", "C".repeat(43)); + + let runtime = tokio::runtime::Runtime::new().expect("the stub's runtime starts"); + let server = runtime.block_on(MockServer::start()); + let base_url = server.uri(); + + Python::attach(|py| { + let module = evidence_client_module(py); + let client_class = module.getattr("EvidenceClient").expect("the class exists"); + let client = client_class + .call1(( + base_url.as_str(), + python_json(py, &trusted_jwks), + "test-token", + )) + .expect("the client is constructed"); + + let prepared = client + .call_method1("prepare", (python_json(py, &request_spec_json()),)) + .expect("the specification is accepted"); + let nonce: String = prepared + .getattr("request_nonce") + .expect("the nonce getter exists") + .extract() + .expect("the nonce is a string"); + + let body = sign(&evidence_for(&nonce, &subject_binding), &signing_key); + // `.expect(1)`: if the second, rejected `send` below reached the + // network after all, the stub would see a second request and the + // mock's own cardinality check would fail when `server` drops. + runtime.block_on( + Mock::given(method("POST")) + .and(path_matcher("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(body, EVIDENCE_JWS_MEDIA_TYPE), + ) + .expect(1) + .mount(&server), + ); + + client + .call_method1("send", (&prepared,)) + .expect("the first send happens"); + + let error = client + .call_method1("send", (&prepared,)) + .expect_err("a second send with the same prepared request is refused locally"); + let kind: String = error + .value(py) + .getattr("kind") + .expect("the exception carries a kind") + .extract() + .expect("kind is a string"); + assert_eq!(kind, "configuration"); + }); +} + +/// The companion negative case for the golden fixture: signed with a fixed, +/// canonical nonce, so checking it against a freshly prepared request (whose +/// nonce is different on every run) has to fail verification, not just +/// happen to succeed by construction. +#[test] +fn a_stale_fixture_response_fails_verification_against_a_live_prepared_request() { + let fixtures_dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")); + let trusted_jwks: serde_json::Value = serde_json::from_slice( + &fs::read(fixtures_dir.join("jwks.json")).expect("the JWKS fixture exists"), + ) + .expect("the JWKS fixture parses"); + let stale_response_body = + fs::read(fixtures_dir.join("response.jws.json")).expect("the response fixture exists"); + + let runtime = tokio::runtime::Runtime::new().expect("the stub's runtime starts"); + let server = runtime.block_on(MockServer::start()); + let base_url = server.uri(); + runtime.block_on( + Mock::given(method("POST")) + .and(path_matcher("/v1/evidence")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(stale_response_body, EVIDENCE_JWS_MEDIA_TYPE), + ) + .expect(1) + .mount(&server), + ); + + // Mirrors the golden fixture's own policy document (see + // `tests/golden_fixture.rs`) in every field except the nonce, which + // `prepare()` mints fresh below: the fixture's response was signed for + // its own canonical, fixed nonce, never this one. + let spec_json = serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "evidence_type": "urn:example:evidence-type:v1", + "issued_by": "urn:example:issuer", + "provided_by": "urn:example:provider", + "configuration_revision": format!("sha256:{}", "0".repeat(64)), + "expected_assurance_profile": "local", + "subjects": [ + { "role": "subject", "selector_profile": "national-id" } + ], + "expected_outputs": [ + { "concept": "urn:example:concept:status-holds", "form": "boolean" } + ], + "maximum_assertion_lifetime_seconds": 3650 * 24 * 60 * 60_i64, + "clock_skew_seconds": 30, + "subject_expectations": "accept_first_use", + }); + + Python::attach(|py| { + let module = evidence_client_module(py); + let client_class = module.getattr("EvidenceClient").expect("the class exists"); + let client = client_class + .call1(( + base_url.as_str(), + python_json(py, &trusted_jwks), + "test-token", + )) + .expect("the client is constructed"); + + let prepared = client + .call_method1("prepare", (python_json(py, &spec_json),)) + .expect("the specification is accepted"); + let response = client + .call_method1("send", (&prepared,)) + .expect("the stub answers, with its stale fixture body"); + + let error = client + .call_method1("verify", (&prepared, &response)) + .expect_err("a response signed for a different nonce fails verification"); + let kind: String = error + .value(py) + .getattr("kind") + .expect("the exception carries a kind") + .extract() + .expect("kind is a string"); + assert_eq!(kind, "verification"); + }); +} diff --git a/crates/registry-evidence-client-py/tests/python/bootstrap.py b/crates/registry-evidence-client-py/tests/python/bootstrap.py new file mode 100644 index 000000000..945164638 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/bootstrap.py @@ -0,0 +1,98 @@ +"""Builds the `registry_evidence_client` extension module via plain `cargo +build` (no maturin) and makes it importable, for the stdlib-only Python test +suite. + +Every test file in this directory begins with the same few lines, inserting +this file's own directory onto `sys.path` before importing it. That is +deliberate: it works identically whether a test file is run directly, through +`python3 -m unittest discover`, or by dotted module name, without this +directory needing to be a real Python package (no `__init__.py`) or without +depending on how `unittest discover`'s top-level and start directories happen +to be set. See the crate's own README for the exact command this suite is +run with. +""" + +from __future__ import annotations + +import pathlib +import platform +import shutil +import subprocess +import sys + +_CRATE_ROOT = pathlib.Path(__file__).resolve().parents[2] +_WORKSPACE_ROOT = _CRATE_ROOT.parents[1] +_MODULE_NAME = "registry_evidence_client" +_TARGET_DEBUG = _WORKSPACE_ROOT / "target" / "debug" +_IMPORT_DIR = _TARGET_DEBUG / "python_module" + +_built = False + + +def _built_cdylib_path() -> pathlib.Path: + system = platform.system() + if system == "Darwin": + name = f"lib{_MODULE_NAME}.dylib" + elif system == "Linux": + name = f"lib{_MODULE_NAME}.so" + else: + raise RuntimeError( + f"the Python suite's cargo-build bootstrap does not know the " + f"cdylib naming convention for {system!r}; macOS and Linux are " + f"the only platforms this crate's local test bootstrap supports" + ) + return _TARGET_DEBUG / name + + +def ensure_built() -> None: + """Build the extension module and put it on `sys.path`, once per process. + + This shells out to `cargo build` with this crate's own `extension-module` + feature, exactly the command the crate's README documents for a manual + build. It requires `python3` on `PATH` at build time: PyO3's own build + script probes the interpreter it is building against. Short of maturin, + which this crate's build/test approach deliberately keeps out of CI (see + the README), there is no way around that requirement. + """ + global _built + if _built: + return + + subprocess.run( + [ + "cargo", + "build", + "--locked", + "-p", + "registry-evidence-client-py", + "--lib", + "--features", + "registry-evidence-client-py/extension-module", + ], + cwd=_WORKSPACE_ROOT, + check=True, + ) + + built_path = _built_cdylib_path() + if not built_path.is_file(): + raise RuntimeError( + f"cargo build reported success but {built_path} does not exist; " + f"the crate's `[lib] name` or this bootstrap's naming convention " + f"has drifted" + ) + + _IMPORT_DIR.mkdir(parents=True, exist_ok=True) + # CPython's import machinery accepts a plain `.so` suffix for an + # extension module on both macOS and Linux, with no ABI or version tag + # needed: confirmed by importing a module built and renamed exactly this + # way. The copy (not the original build artifact) is what every test + # imports, so the workspace's shared `target/debug/` directory, which + # already holds Cargo's own outputs for every crate, never gains a + # Python-import-shaped file of its own. + imported_path = _IMPORT_DIR / f"{_MODULE_NAME}.so" + shutil.copyfile(built_path, imported_path) + + if str(_IMPORT_DIR) not in sys.path: + sys.path.insert(0, str(_IMPORT_DIR)) + + _built = True diff --git a/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py b/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py new file mode 100644 index 000000000..b6dfb6951 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py @@ -0,0 +1,73 @@ +"""Shared constants for the Python test suite. + +`VALID_JWKS` is a syntactically well-formed key set, never used to verify +anything for real: `EvidenceClient.send` never parses or verifies the +response body it reads (only `verify`/`verify_as_of` do, via +`registry-evidence-verifier`), so every test in this suite that only calls +`send` needs a key set shaped correctly enough to construct a client, not one +that actually corresponds to any signing key. Its key material is an +obviously placeholder, all-zero value, distinct from the real committed +golden fixture key at `tests/fixtures/jwks.json`. + +`request_spec()` is the specification every `send`-level test prepares +against, matching `crates/registry-evidence-client-py/tests/happy_path.rs`'s +own `request_spec_json`. `subject_expectations` is `"accept_first_use"`, so +nothing here needs a subject binding decided ahead of time. +""" + +from __future__ import annotations + +import json + +VALID_JWKS = { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": "construction-test-key", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + } + ] +} + + +def request_spec() -> dict: + return { + "requirement": "urn:example:requirement:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "evidence_type": "urn:example:evidence-type:v1", + "issued_by": "urn:example:issuer", + "provided_by": "urn:example:provider", + "configuration_revision": "sha256:" + "0" * 64, + "expected_assurance_profile": "local", + "subjects": [{"role": "subject", "selector_profile": "national-id"}], + "expected_outputs": [ + {"concept": "urn:example:concept:status-holds", "form": "boolean"} + ], + "maximum_assertion_lifetime_seconds": 300, + "clock_skew_seconds": 60, + "subject_expectations": "accept_first_use", + } + + +def problem_body( + status: int, code: str, operation: str = "01JQ0QZ8YHZ0000000000000AB" +) -> bytes: + """A body satisfying the Evidence problem contract exactly: `type`, + `title`, `status`, `code`, and `operation`, and nothing else (the server + side, `crates/registry-evidence-client/src/problem.rs`, denies unknown + fields). `operation` defaults to the same fixed value + `problem.rs`'s own tests use, bounded alphanumeric, so tests can assert + it survives unchanged into the mapped exception's `operation` attribute. + """ + return json.dumps( + { + "type": "https://registrystack.org/problems/evidence", + "title": "stub problem", + "status": status, + "code": code, + "operation": operation, + } + ).encode("utf-8") diff --git a/crates/registry-evidence-client-py/tests/python/helpers/stub_server.py b/crates/registry-evidence-client-py/tests/python/helpers/stub_server.py new file mode 100644 index 000000000..a21a15e98 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/helpers/stub_server.py @@ -0,0 +1,132 @@ +"""A minimal, stdlib-only HTTP stub for the Python Evidence client suite. + +Not a mock framework: each test mounts exactly the routes it needs on +`StubServer.routes` and reads `StubServer.requests` back for anything it +wants to assert about what the client sent. Modeled on +`crates/registry-evidence-client-node/__test__/helpers/stub-server.js`, +adapted to Python's own `http.server` instead of Node's `http` module. +""" + +from __future__ import annotations + +import http.server +import sys +import threading +import time +from dataclasses import dataclass, field + + +class _StubHTTPServer(http.server.ThreadingHTTPServer): + def handle_error(self, request, client_address) -> None: # noqa: ANN001 + # The oversized-response test deliberately makes the client stop + # reading partway through a response; on this server's background + # thread, that surfaces as a write failing with `ConnectionResetError` + # (or, on some platforms, `BrokenPipeError`). That is the expected + # shape of "the client gave up first," not a defect, so only a + # genuinely unexpected exception still gets the stdlib's default + # traceback-to-stderr reporting. + _, exc_value, _ = sys.exc_info() + if isinstance(exc_value, (ConnectionResetError, BrokenPipeError)): + return + super().handle_error(request, client_address) + + +@dataclass +class RecordedRequest: + method: str + path: str + headers: dict[str, str] + body: bytes + + +@dataclass +class StubRoute: + status: int + headers: dict[str, str] = field(default_factory=dict) + body: bytes = b"" + # Only the GIL-release concurrency test uses this: it holds the response + # back long enough to make two concurrent client calls observably + # overlap (or fail to) in wall-clock time. + delay_seconds: float = 0.0 + + +class StubServer: + """One loopback HTTP server for the duration of a single test. + + `routes` is keyed `"METHOD /path"` (for example + `"GET /v1/evidence-definitions"`), matching exactly the request line the + Evidence client sends: no query string, no host. A request for a route + that was not mounted gets a bare 404 with an empty body, which the client + reports as a protocol failure regardless of which endpoint it hit. + """ + + def __init__(self, routes: dict[str, StubRoute]): + self.routes = routes + self.requests: list[RecordedRequest] = [] + lock = threading.Lock() + routes_ref = self.routes + requests_ref = self.requests + + class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _handle(self) -> None: + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length else b"" + with lock: + requests_ref.append( + RecordedRequest( + method=self.command, + path=self.path, + # HTTP header names are case-insensitive, and + # reqwest sends them lowercase; unlike Node's + # `http` module (which lowercases incoming + # header names for you), Python's `http.server` + # preserves whatever casing arrived on the wire, + # so this normalizes it the same way for callers + # comparing against a literal name. + headers={k.lower(): v for k, v in self.headers.items()}, + body=body, + ) + ) + route = routes_ref.get(f"{self.command} {self.path}") + if route is None: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + if route.delay_seconds: + time.sleep(route.delay_seconds) + self.send_response(route.status) + for name, value in route.headers.items(): + self.send_header(name, value) + self.send_header("Content-Length", str(len(route.body))) + self.end_headers() + if route.body: + self.wfile.write(route.body) + + def do_GET(self) -> None: # noqa: N802 - stdlib's own naming convention + self._handle() + + def do_POST(self) -> None: # noqa: N802 + self._handle() + + def log_message(self, log_format: str, *args: object) -> None: + # The stdlib default writes every request to stderr; this + # suite's own assertions are the record of what happened, not + # the console. + pass + + self._httpd = _StubHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + + @property + def base_url(self) -> str: + host, port = self._httpd.server_address[:2] + return f"http://{host}:{port}" + + def close(self) -> None: + self._httpd.shutdown() + self._httpd.server_close() + self._thread.join() diff --git a/crates/registry-evidence-client-py/tests/python/test_concurrency.py b/crates/registry-evidence-client-py/tests/python/test_concurrency.py new file mode 100644 index 000000000..0cadceefc --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_concurrency.py @@ -0,0 +1,93 @@ +"""Every blocking `EvidenceClient` method releases the GIL for its I/O. + +The client blocks on its own private tokio runtime from Rust (`py.detach` +around `self.runtime.block_on(...)` in `src/lib.rs`), specifically so a +long-running Evidence call does not stall every other Python thread for its +whole duration. This proves that by racing two clients against a +deliberately slow stub endpoint: if the GIL were held for the duration of the +blocking call, the two calls would serialize and the pair would take about +twice as long as either one alone. +""" + +from __future__ import annotations + +import pathlib +import sys +import threading +import time +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import fixtures # noqa: E402 +import registry_evidence_client as revc # noqa: E402 +from stub_server import StubRoute, StubServer # noqa: E402 + +JSON_MEDIA_TYPE = "application/json" +RESPONSE_DELAY_SECONDS = 0.4 +JOIN_TIMEOUT_SECONDS = 5.0 + +DEFINITIONS_DOCUMENT_BODY = ( + b'{"schema": "registry.evidence-definitions/v1", "assuranceProfile": "local",' + b' "configurationRevision": "r", "issuedBy": "i", "providedBy": "p",' + b' "definitions": []}' +) + + +class ConcurrencyTest(unittest.TestCase): + def test_two_concurrent_calls_overlap_instead_of_serializing(self): + server = StubServer( + { + "GET /v1/evidence-definitions": StubRoute( + status=200, + headers={"Content-Type": JSON_MEDIA_TYPE}, + body=DEFINITIONS_DOCUMENT_BODY, + delay_seconds=RESPONSE_DELAY_SECONDS, + ) + } + ) + self.addCleanup(server.close) + + barrier = threading.Barrier(2) + errors: list[BaseException] = [] + + def call_discover() -> None: + try: + client = revc.EvidenceClient( + server.base_url, fixtures.VALID_JWKS, "test-token" + ) + barrier.wait(timeout=JOIN_TIMEOUT_SECONDS) + client.discover() + except BaseException as error: # noqa: BLE001 - captured, not swallowed + errors.append(error) + + threads = [threading.Thread(target=call_discover) for _ in range(2)] + started_at = time.monotonic() + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=JOIN_TIMEOUT_SECONDS) + elapsed = time.monotonic() - started_at + + for thread in threads: + self.assertFalse( + thread.is_alive(), + "a client thread did not finish within the bounded timeout", + ) + self.assertEqual(errors, []) + + # Serialized, the pair would take roughly 2x the single-call delay; + # overlapping, it takes roughly 1x. The threshold sits well below 2x + # so ordinary scheduling jitter cannot make a passing run look like a + # regression, while a genuinely serialized pair still fails it. + self.assertLess(elapsed, RESPONSE_DELAY_SECONDS * 1.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-evidence-client-py/tests/python/test_construction.py b/crates/registry-evidence-client-py/tests/python/test_construction.py new file mode 100644 index 000000000..7b0481fa1 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_construction.py @@ -0,0 +1,73 @@ +"""Construction-time refusals. + +Every case here is caught inside `EvidenceClientConfig::validate` (see +`crates/registry-evidence-client/src/config.rs`), before any HTTP client is +even built, let alone any request sent. +""" + +from __future__ import annotations + +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import fixtures # noqa: E402 +import registry_evidence_client as revc # noqa: E402 + + +class ConstructionTest(unittest.TestCase): + def test_a_non_https_non_loopback_base_url_is_refused(self): + with self.assertRaises(revc.ConfigurationError) as raised: + revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, "test-token") + error = raised.exception + self.assertEqual(error.kind, "configuration") + # A caller branches on `kind`, never by parsing `str(error)`: the + # message is never JSON-shaped. + self.assertFalse(str(error).strip().startswith("{")) + + def test_an_empty_key_set_is_refused(self): + with self.assertRaises(revc.ConfigurationError) as raised: + revc.EvidenceClient("https://example.org", {"keys": []}, "test-token") + self.assertEqual(raised.exception.kind, "configuration") + + def test_a_base_url_with_an_empty_path_segment_is_refused(self): + with self.assertRaises(revc.ConfigurationError) as raised: + revc.EvidenceClient( + "https://example.org/a//b", fixtures.VALID_JWKS, "test-token" + ) + self.assertEqual(raised.exception.kind, "configuration") + + def test_a_loopback_http_base_url_is_accepted(self): + # Not a refusal case: confirms the three refusals above are testing + # the specific rules, not "any base URL fails". Port 1 is never + # connected to here; construction never performs I/O. + client = revc.EvidenceClient( + "http://127.0.0.1:1", fixtures.VALID_JWKS, "test-token" + ) + self.assertIsInstance(client, revc.EvidenceClient) + + def test_every_exception_carries_every_stable_attribute(self): + with self.assertRaises(revc.EvidenceClientError) as raised: + revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, "test-token") + for attribute in ( + "kind", + "status", + "code", + "operation", + "retry_after_seconds", + "transport_kind", + "token_kind", + ): + self.assertTrue(hasattr(raised.exception, attribute), attribute) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-evidence-client-py/tests/python/test_discovery.py b/crates/registry-evidence-client-py/tests/python/test_discovery.py new file mode 100644 index 000000000..0534479ec --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_discovery.py @@ -0,0 +1,100 @@ +"""`discover()` and `fetch_jwks()` against a stub server. + +`discover()` requires a credential (`Authorization: Bearer `); +`fetch_jwks()` never sends one, since a key set fetched from the same origin +as the response it would verify establishes nothing (see `client.rs`'s own +`Credential::None` for that endpoint). Both endpoints return a plain Python +dict built from the deployment's JSON, not a dataclass: this file checks the +camelCase shape survives untouched. +""" + +from __future__ import annotations + +import json +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import fixtures # noqa: E402 +import registry_evidence_client as revc # noqa: E402 +from stub_server import StubRoute, StubServer # noqa: E402 + +JSON_MEDIA_TYPE = "application/json" +JWKS_MEDIA_TYPE = "application/jwk-set+json" + +DEFINITIONS_DOCUMENT = { + "schema": "registry.evidence-definitions/v1", + "assuranceProfile": "local", + "configurationRevision": "test-revision-1", + "issuedBy": "https://issuer.example.test", + "providedBy": "https://provider.example.test", + "definitions": [], +} + + +class DiscoveryTest(unittest.TestCase): + def setUp(self) -> None: + self.server = StubServer({}) + self.addCleanup(self.server.close) + + def _client(self): + return revc.EvidenceClient( + self.server.base_url, fixtures.VALID_JWKS, "test-token" + ) + + def test_discover_returns_the_definitions_document_as_a_dict(self): + self.server.routes["GET /v1/evidence-definitions"] = StubRoute( + status=200, + headers={"Content-Type": JSON_MEDIA_TYPE}, + body=json.dumps(DEFINITIONS_DOCUMENT).encode("utf-8"), + ) + document = self._client().discover() + self.assertEqual(document, DEFINITIONS_DOCUMENT) + + def test_discover_sends_a_bearer_credential(self): + self.server.routes["GET /v1/evidence-definitions"] = StubRoute( + status=200, + headers={"Content-Type": JSON_MEDIA_TYPE}, + body=json.dumps(DEFINITIONS_DOCUMENT).encode("utf-8"), + ) + self._client().discover() + self.assertEqual(len(self.server.requests), 1) + self.assertEqual( + self.server.requests[0].headers.get("authorization"), + "Bearer test-token", + ) + + def test_fetch_jwks_returns_the_committed_fixture_as_a_dict(self): + fixture_path = ( + pathlib.Path(__file__).resolve().parents[1] / "fixtures" / "jwks.json" + ) + jwks_bytes = fixture_path.read_bytes() + self.server.routes["GET /.well-known/evidence/jwks.json"] = StubRoute( + status=200, + headers={"Content-Type": JWKS_MEDIA_TYPE}, + body=jwks_bytes, + ) + document = self._client().fetch_jwks() + self.assertEqual(document, json.loads(jwks_bytes)) + + def test_fetch_jwks_sends_no_credential(self): + self.server.routes["GET /.well-known/evidence/jwks.json"] = StubRoute( + status=200, + headers={"Content-Type": JWKS_MEDIA_TYPE}, + body=b'{"keys": []}', + ) + self._client().fetch_jwks() + self.assertEqual(len(self.server.requests), 1) + self.assertNotIn("authorization", self.server.requests[0].headers) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-evidence-client-py/tests/python/test_drift.py b/crates/registry-evidence-client-py/tests/python/test_drift.py new file mode 100644 index 000000000..a245d3f5f --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_drift.py @@ -0,0 +1,156 @@ +"""The committed `__init__.pyi` names exactly the live compiled surface. + +PyO3 does not emit a `.pyi` on its own; `__init__.pyi` is hand-written and +nothing forces it to track `src/lib.rs` automatically. This is the Python +analog of `registry-evidence-client-node`'s own `__test__/drift.test.js`: +introspect the built module's real classes and assert the stub names exactly +what exists, in both directions, so an added or removed method/attribute on +either side fails this test rather than silently drifting. + +Two different techniques are needed, for two different shapes of drift: + +- The four plain classes (`EvidenceClient`, `PreparedEvidenceRequest`, + `RawEvidenceResponse`, `VerifiedEvidence`) expose their methods and + attributes as ordinary class-level descriptors, visible to `vars(cls)` + without ever constructing an instance. +- The nine exception classes set their stable attributes (`kind`, `status`, + ...) per instance, at raise time (`to_py_err`'s `set_attr!` calls in + `src/lib.rs`), which `vars(cls)` on the class itself cannot see at all. + Those are checked against a hardcoded reference list instead, drawn + directly from that same `set_attr!` call list. +""" + +from __future__ import annotations + +import ast +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +_STUB_PATH = ( + _TESTS_DIR.parent.parent / "python" / "registry_evidence_client" / "__init__.pyi" +) +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import registry_evidence_client as revc # noqa: E402 + +# Exactly the attributes `to_py_err` sets on every raised exception instance, +# in `crates/registry-evidence-client-py/src/lib.rs`. Update this list only in +# lockstep with that function. +SETTABLE_EXCEPTION_ATTRIBUTES = { + "kind", + "status", + "code", + "operation", + "retry_after_seconds", + "transport_kind", + "token_kind", +} + +EXCEPTION_SUBCLASS_NAMES = { + "ConfigurationError", + "NonceError", + "TokenError", + "TransportError", + "DeniedError", + "NotAvailableError", + "ProtocolError", + "VerificationError", +} + +PLAIN_CLASS_NAMES = { + "EvidenceClient", + "PreparedEvidenceRequest", + "RawEvidenceResponse", + "VerifiedEvidence", +} + +# The only class whose stub declares a constructor; PyO3 exposes it as +# `__new__`, never `__init__` (confirmed by introspecting the compiled +# `EvidenceClient` class directly: `vars()` has no `__init__` key at all). +CONSTRUCTOR_NAME_MAP = {"__init__": "__new__"} + + +def _stub_tree() -> ast.Module: + return ast.parse(_STUB_PATH.read_text(encoding="utf-8")) + + +def _stub_class_defs(tree: ast.Module) -> dict[str, ast.ClassDef]: + return {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)} + + +def _stub_member_names(class_def: ast.ClassDef) -> set[str]: + names: set[str] = set() + for item in class_def.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + names.add(item.name) + elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + names.add(item.target.id) + return names + + +def _stub_base_names(class_def: ast.ClassDef) -> set[str]: + return {base.id for base in class_def.bases if isinstance(base, ast.Name)} + + +def _live_member_names(cls: type) -> set[str]: + return set(vars(cls)) - {"__doc__", "__module__"} + + +class DriftTest(unittest.TestCase): + def setUp(self) -> None: + self.tree = _stub_tree() + self.stub_classes = _stub_class_defs(self.tree) + + def test_the_stub_declares_exactly_the_live_module_top_level_names(self): + stub_names = set(self.stub_classes) + live_names = {name for name in dir(revc) if not name.startswith("_")} + self.assertEqual(stub_names, live_names) + + def test_every_plain_class_member_matches_in_both_directions(self): + for name in PLAIN_CLASS_NAMES: + with self.subTest(cls=name): + stub_names = _stub_member_names(self.stub_classes[name]) + stub_names = { + CONSTRUCTOR_NAME_MAP.get(member, member) for member in stub_names + } + live_names = _live_member_names(getattr(revc, name)) + self.assertEqual( + stub_names, + live_names, + f"{name}: stub and compiled surface disagree", + ) + + def test_the_base_exception_declares_exactly_the_settable_attributes(self): + stub_names = _stub_member_names(self.stub_classes["EvidenceClientError"]) + self.assertEqual(stub_names, SETTABLE_EXCEPTION_ATTRIBUTES) + + def test_every_exception_subclass_is_declared_and_live_under_the_base(self): + stub_subclass_names = set(self.stub_classes) & EXCEPTION_SUBCLASS_NAMES + self.assertEqual(stub_subclass_names, EXCEPTION_SUBCLASS_NAMES) + for name in EXCEPTION_SUBCLASS_NAMES: + with self.subTest(cls=name): + # The stub declares no attributes of its own on the subclass: + # every stable attribute lives on the shared base only. + self.assertEqual(_stub_member_names(self.stub_classes[name]), set()) + self.assertEqual( + _stub_base_names(self.stub_classes[name]), {"EvidenceClientError"} + ) + live_cls = getattr(revc, name) + self.assertTrue(issubclass(live_cls, revc.EvidenceClientError)) + + def test_the_base_exception_is_declared_and_live_as_an_exception(self): + self.assertEqual( + _stub_base_names(self.stub_classes["EvidenceClientError"]), {"Exception"} + ) + self.assertTrue(issubclass(revc.EvidenceClientError, Exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-evidence-client-py/tests/python/test_errors.py b/crates/registry-evidence-client-py/tests/python/test_errors.py new file mode 100644 index 000000000..71ca0c9d3 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_errors.py @@ -0,0 +1,139 @@ +"""Error mapping from the deployment's answer onto the client's own stable +exception kinds. + +None of these responses are signed, and none need to be: `send()` only +validates status, content type, and reads bounded bytes (see +`expect_success` in `crates/registry-evidence-client/src/client.rs`); only +`verify()`/`verify_as_of()` ever parse or verify the JWS, and nothing in this +file calls either. Every status/code combination below mirrors +`crates/registry-evidence-client/src/problem.rs`'s own mapping table exactly. +""" + +from __future__ import annotations + +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import fixtures # noqa: E402 +import registry_evidence_client as revc # noqa: E402 +from stub_server import StubRoute, StubServer # noqa: E402 + +PROBLEM_MEDIA_TYPE = "application/problem+json" +EVIDENCE_JWS_MEDIA_TYPE = "application/jose+json" +OPERATION = "01JQ0QZ8YHZ0000000000000AB" + + +class ErrorMappingTest(unittest.TestCase): + def setUp(self) -> None: + self.server = StubServer({}) + self.addCleanup(self.server.close) + + def _client(self, **kwargs): + return revc.EvidenceClient( + self.server.base_url, fixtures.VALID_JWKS, "test-token", **kwargs + ) + + def _send(self, client): + prepared = client.prepare(fixtures.request_spec()) + return client.send(prepared) + + def test_401_maps_to_denied(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=401, + headers={"Content-Type": PROBLEM_MEDIA_TYPE}, + body=fixtures.problem_body(401, "authentication_failed"), + ) + with self.assertRaises(revc.DeniedError) as raised: + self._send(self._client()) + error = raised.exception + self.assertEqual(error.kind, "denied") + self.assertEqual(error.status, 401) + self.assertEqual(error.code, "authentication_failed") + self.assertIsNone(error.retry_after_seconds) + self.assertEqual(error.operation, OPERATION) + + def test_403_maps_to_denied(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=403, + headers={"Content-Type": PROBLEM_MEDIA_TYPE}, + body=fixtures.problem_body(403, "not_authorized"), + ) + with self.assertRaises(revc.DeniedError) as raised: + self._send(self._client()) + error = raised.exception + self.assertEqual(error.status, 403) + self.assertEqual(error.code, "not_authorized") + + def test_429_maps_to_denied_with_retry_after(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=429, + headers={"Content-Type": PROBLEM_MEDIA_TYPE, "Retry-After": "30"}, + body=fixtures.problem_body(429, "rate_limited"), + ) + with self.assertRaises(revc.DeniedError) as raised: + self._send(self._client()) + error = raised.exception + self.assertEqual(error.status, 429) + self.assertEqual(error.retry_after_seconds, 30) + + def test_422_with_the_not_available_code_maps_to_not_available(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=422, + headers={"Content-Type": PROBLEM_MEDIA_TYPE}, + body=fixtures.problem_body(422, "evidence_not_available"), + ) + with self.assertRaises(revc.NotAvailableError) as raised: + self._send(self._client()) + error = raised.exception + self.assertEqual(error.kind, "not_available") + self.assertEqual(error.operation, OPERATION) + + def test_400_with_an_ordinary_code_maps_to_protocol(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=400, + headers={"Content-Type": PROBLEM_MEDIA_TYPE}, + body=fixtures.problem_body(400, "bad_request"), + ) + with self.assertRaises(revc.ProtocolError) as raised: + self._send(self._client()) + error = raised.exception + self.assertEqual(error.kind, "protocol") + self.assertEqual(error.status, 400) + self.assertEqual(error.code, "bad_request") + + def test_a_success_with_the_wrong_media_type_maps_to_protocol(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=200, + headers={"Content-Type": "text/plain"}, + body=b"not a JWS", + ) + with self.assertRaises(revc.ProtocolError) as raised: + self._send(self._client()) + error = raised.exception + self.assertEqual(error.status, 200) + self.assertIsNone(error.code) + + def test_an_oversized_response_maps_to_transport(self): + self.server.routes["POST /v1/evidence"] = StubRoute( + status=200, + headers={"Content-Type": EVIDENCE_JWS_MEDIA_TYPE}, + body=b"x" * 64, + ) + with self.assertRaises(revc.TransportError) as raised: + self._send(self._client(max_response_bytes=16)) + error = raised.exception + self.assertEqual(error.kind, "transport") + self.assertEqual(error.transport_kind, "response_too_large") + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py b/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py new file mode 100644 index 000000000..dd2edac29 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py @@ -0,0 +1,63 @@ +"""A prepared request allows exactly one `send()`. + +`PreparedEvidenceRequest.claim_single_send()` (see +`crates/registry-evidence-client/src/prepare.rs`) spends its claim before any +I/O, so a second `send()` on the same prepared object must raise +`ConfigurationError` without a second request ever reaching the deployment: +resending the same nonce would earn a second source access and a second audit +entry there. +""" + +from __future__ import annotations + +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import fixtures # noqa: E402 +import registry_evidence_client as revc # noqa: E402 +from stub_server import StubRoute, StubServer # noqa: E402 + +EVIDENCE_JWS_MEDIA_TYPE = "application/jose+json" + + +class OneSendGuardTest(unittest.TestCase): + def setUp(self) -> None: + self.server = StubServer({}) + self.addCleanup(self.server.close) + self.server.routes["POST /v1/evidence"] = StubRoute( + status=200, + headers={"Content-Type": EVIDENCE_JWS_MEDIA_TYPE}, + # `send()` never parses or verifies this body (only `verify()` and + # `verify_as_of()` do), so an obviously-fake JWS is enough here. + body=b'{"payload": "not-a-real-jws"}', + ) + + def test_a_second_send_is_refused_without_reaching_the_network(self): + client = revc.EvidenceClient( + self.server.base_url, fixtures.VALID_JWKS, "test-token" + ) + prepared = client.prepare(fixtures.request_spec()) + + client.send(prepared) + self.assertEqual(len(self.server.requests), 1) + + with self.assertRaises(revc.ConfigurationError) as raised: + client.send(prepared) + self.assertEqual(raised.exception.kind, "configuration") + + # The guard is claimed before any I/O, so the refused second attempt + # never reaches the stub at all. + self.assertEqual(len(self.server.requests), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index 89ae4e177..482c3f044 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -38,7 +38,9 @@ re-implements no part of evaluation, signing, or verification. It sits outside the frozen Version 1 runtime contract, and its source is covered by the same source-product and domain neutrality checks. `registry-evidence-client-node` is a thin napi-rs binding over `registry-evidence-client` for Node.js callers, and -carries the same neutrality checks. +carries the same neutrality checks. `registry-evidence-client-py` is the same +binding pattern for Python callers, via PyO3, and carries the same neutrality +checks. Selected `registry-platform-*` primitives may be reused only when their existing contracts fit Evidence directly. The approved candidates are audit, crypto, diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index 3df7f670c..4b6acea4b 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -12,6 +12,7 @@ for source_file in $( "$repository_root/crates/registry-evidence/src" \ "$repository_root/crates/registry-evidence-client/src" \ "$repository_root/crates/registry-evidence-client-node/src" \ + "$repository_root/crates/registry-evidence-client-py/src" \ "$repository_root/crates/registry-evidence-verifier/src" \ "$repository_root/crates/registry-evidencectl/src" \ -g '*.rs' | sort @@ -138,6 +139,7 @@ if rg -n -i 'dhis2|opencrvs' \ "$repository_root/crates/registry-evidence/Cargo.toml" \ "$repository_root/crates/registry-evidence-client/Cargo.toml" \ "$repository_root/crates/registry-evidence-client-node/Cargo.toml" \ + "$repository_root/crates/registry-evidence-client-py/Cargo.toml" \ "$repository_root/crates/registry-evidence-verifier/Cargo.toml" \ "$repository_root/crates/registry-evidencectl/Cargo.toml" \ "$repository_root/Cargo.toml"; then From 7cd007b0e57d6f53733e9ca20faff313cddfe037 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 20:33:42 +0700 Subject: [PATCH 32/67] fix(evidence): tighten Python binding safety, docs, and idempotency Eight fix-pass items in the PyO3 client binding: release the GIL for the constructor's TLS trust-store load (with a self-calibrating concurrency test), replace a vacuous redaction test with real planted canaries, correct a panic-boundary comment that named the wrong unguarded path, narrow the base exception's docstring to the two failures that actually escape it, give the four pyclasses their real Python module so introspection doesn't report `builtins`, make the package's `__init__.py` safe to reload, and record why one match arm deliberately reports no `nonce_kind`. Signed-off-by: Jeremi Joslin --- .../registry_evidence_client/__init__.py | 23 +++- .../registry_evidence_client/__init__.pyi | 7 +- .../src/convert.rs | 128 ++++++++++++++++-- crates/registry-evidence-client-py/src/lib.rs | 37 +++-- .../tests/python/test_concurrency.py | 77 +++++++++++ .../tests/python/test_reload.py | 59 ++++++++ 6 files changed, 298 insertions(+), 33 deletions(-) create mode 100644 crates/registry-evidence-client-py/tests/python/test_reload.py diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py index 3617f8435..7e2947ba2 100644 --- a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py @@ -10,9 +10,20 @@ from .registry_evidence_client import * # noqa: F401,F403 -# `import *` above also binds the submodule's own name (`registry_evidence_client`) -# into this package's namespace, alongside the classes and exceptions it -# actually exports; drop it so the public surface matches the committed -# `__init__.pyi` exactly, with nothing extra for the stub-drift test to -# special-case. -del registry_evidence_client +# The import system, not the `import *` above, is what binds the submodule's +# own name (`registry_evidence_client`) into this package's namespace: +# loading a submodule sets it as an attribute of its parent package. Drop it +# so the public surface matches the committed `__init__.pyi` exactly, with +# nothing extra for the stub-drift test to special-case. `pop` (not `del`) +# keeps this idempotent under `importlib.reload`, which re-executes this body +# against the existing namespace rather than a fresh one: on a second run the +# submodule is already in `sys.modules`, so the import system does not +# re-bind the attribute here, and a plain `del` would raise `NameError` on a +# name no longer present. +# +# The drop is not perfectly invisible: afterward, `import +# registry_evidence_client.registry_evidence_client` followed by attribute +# access still raises `AttributeError`, while `from registry_evidence_client +# import registry_evidence_client as sub` keeps working, since that form +# falls back to `sys.modules` directly. +globals().pop("registry_evidence_client", None) diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi index d17c5c7d5..cc00b6763 100644 --- a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi @@ -21,7 +21,7 @@ None of this crosses into `asyncio`; there is no `async def` anywhere here. from typing import Any, Optional, Sequence, Union class EvidenceClientError(Exception): - """Base exception for every failure this client reports. + """Base exception for every mapped failure this client reports. `kind` is always present, one of "configuration", "nonce", "token", "transport", "denied", "not_available", "protocol", or "verification". @@ -32,6 +32,11 @@ class EvidenceClientError(Exception): No attribute here ever carries response bytes, a credential, a header value, a selector value, or a subject binding. + + Two failures escape this hierarchy entirely, since neither is a mapped + failure with a `kind`: the client's internal runtime failing to start, + which raises `RuntimeError`, and a serialization failure on a value this + crate itself constructed, which raises `ValueError`. """ kind: str diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index 8dd8c6f53..f35ceb750 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -686,6 +686,10 @@ pub fn map_client_error(error: &EvidenceClientError) -> MappedError { mapped.operation = error.operation().map(str::to_owned); match error { + // Deliberately no `nonce_kind` field: `NonceError::NotCanonical` is + // constructed only inside `RequestNonce::parse`'s own unit tests, so + // this crate's production path can only ever fail here with + // `NonceError::Entropy`, which carries nothing further to report. EvidenceClientError::Configuration { .. } | EvidenceClientError::Nonce(_) => {} EvidenceClientError::Token(token_error) => insert_token_fields(&mut mapped, token_error), EvidenceClientError::Transport { kind } => { @@ -1220,21 +1224,119 @@ mod tests { assert_eq!(mapped.operation, Some(operation)); } + /// Mirrors the wrapped crate's own redaction tests and the Node binding's + /// `mapped_errors_never_carry_a_credential_key_selector_value_or_subject_binding`: + /// plant a canary value in every place a credential, key, selector value, + /// or subject binding legitimately reaches this crate's own conversion + /// and error-mapping layer, and confirm the mapped failure this crate + /// hands to `to_py_err` never repeats it. A response body never reaches + /// this module at all (`map_client_error` never touches + /// `RawEvidenceResponse`), so it has no case here; the wrapped crate's own + /// test already covers it. + /// + /// Each arrangement first asserts on the specific error it produces, not + /// only on the canary's absence: if a step refused for an unrelated + /// reason instead of carrying the canary to the intended place, that + /// assertion catches it before the canary check could pass vacuously. #[test] - fn map_client_error_never_carries_response_bytes_or_a_selector_value() { - // `Verification(Payload)` is the closest a mapped failure comes to a - // response-shaped cause; its message must still be the fixed, - // uninformative sentence the verifier defines, not a report about the - // payload's own content. - let mapped = map_client_error(&EvidenceClientError::Verification( - VerificationError::Payload, + fn mapped_errors_never_carry_a_credential_key_selector_value_or_subject_binding() { + const CANARY: &str = "secret-canary-value"; + + fn assert_canary_absent(mapped: &MappedError) { + assert!( + !mapped.message.contains(CANARY), + "leaked in message: {}", + mapped.message + ); + if let Some(code) = &mapped.code { + assert!(!code.contains(CANARY), "leaked in code: {code}"); + } + if let Some(operation) = &mapped.operation { + assert!( + !operation.contains(CANARY), + "leaked in operation: {operation}" + ); + } + } + + // A bearer credential shaped exactly as a caller might submit one by + // mistake (here, carrying a trailing newline `BearerToken` refuses): + // the fixed refusal reason must not repeat the credential itself. + // Carried by `TokenError::Invalid`, reached through + // `config_from_parts` -> `token_provider_from_json` -> + // `StaticToken::new`. + let error = config_from_parts( + "https://evidence.example/", + &serde_json::json!({ "keys": [] }), + &Value::String(format!("{CANARY}\n")), + None, + None, + None, + None, + None, + ) + .expect_err("a newline is refused"); + assert!(matches!( + error, + ConfigError::Client(EvidenceClientError::Token(TokenError::Invalid { .. })) )); - assert_eq!( - mapped.message, - "the Evidence response failed verification: Evidence payload is malformed" - ); - assert!(!mapped.message.contains("subject")); - assert!(!mapped.message.contains("selector")); + assert_canary_absent(&map_config_error(&error)); + + // A signing key whose private component is the canary: well-shaped + // JSON, but not a valid Ed25519 scalar, so `PrivateJwk::parse` + // refuses it. The refusal must describe the field (`d`), never echo + // it. Carried by that parse failure, reached through + // `config_from_parts` -> `private_key_jwt_provider_from_json`. + let token = serde_json::json!({ + "private_key_jwt": { + "token_endpoint": "https://issuer.example/token", + "client_id": "test-client", + "client_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "d": CANARY, + }, + } + }); + let error = config_from_parts( + "https://evidence.example/", + &serde_json::json!({ "keys": [] }), + &token, + None, + None, + None, + None, + None, + ) + .expect_err("the malformed key is refused"); + assert!(matches!(error, ConfigError::Shape(_))); + assert_canary_absent(&map_config_error(&error)); + + // A selector value carrying the canary, in a specification refused + // for an unrelated reason (a missing `purpose`): the canary is parsed + // and held in memory (subjects are parsed before `purpose` is + // checked) before the refusal, but the refusal itself must not + // mention it. Carried by the missing-`purpose` `ConversionError` from + // `spec_from_json`. + let mut spec = valid_spec_json(); + spec["subjects"][0]["selector_values"] = serde_json::json!({ "record_reference": CANARY }); + spec.as_object_mut().unwrap().remove("purpose"); + let error = spec_from_json(&spec).expect_err("the missing `purpose` is refused"); + assert_eq!(error, ConversionError::new("`purpose` must be a string")); + assert_canary_absent(&map_conversion_error(&error)); + + // A pinned subject binding carrying the canary, in a specification + // refused for the same unrelated reason: `subject_expectations` is + // likewise parsed before `purpose` is checked. + let mut spec = valid_spec_json(); + spec["subject_expectations"] = serde_json::json!([ + { "role": "subject", "binding": CANARY } + ]); + spec.as_object_mut().unwrap().remove("purpose"); + let error = spec_from_json(&spec).expect_err("the missing `purpose` is refused"); + assert_eq!(error, ConversionError::new("`purpose` must be a string")); + assert_canary_absent(&map_conversion_error(&error)); } #[test] diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs index 7067b8245..d5058370a 100644 --- a/crates/registry-evidence-client-py/src/lib.rs +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -53,8 +53,12 @@ pyo3::create_exception!( registry_evidence_client, EvidenceClientError, PyException, - "Base exception for every failure this client reports. See the module \ - documentation for the attributes every instance carries." + "Base exception for every mapped failure this client reports. See the \ + module documentation for the attributes every instance carries. Two \ + failures escape this hierarchy entirely, since neither is a mapped \ + failure with a `kind`: the client's internal runtime failing to start, \ + which raises `RuntimeError`, and a serialization failure on a value \ + this crate itself constructed, which raises `ValueError`." ); pyo3::create_exception!( @@ -179,7 +183,7 @@ fn serialization_error(what: &str, error: serde_json::Error) -> PyErr { /// public constructor either. It owns the real value directly rather than a /// clone of it: the real type is deliberately not `Clone`, to protect its /// interior single-send flag, and copying it here would defeat that guard. -#[pyclass(name = "PreparedEvidenceRequest")] +#[pyclass(name = "PreparedEvidenceRequest", module = "registry_evidence_client")] struct PreparedEvidenceRequest { inner: RealPreparedEvidenceRequest, } @@ -219,7 +223,7 @@ impl PreparedEvidenceRequest { /// [`EvidenceClient::send`]. It carries no attributes: nothing in it has been /// trusted yet, and `verify` is what judges it, never Python code inspecting /// its bytes directly. -#[pyclass(name = "RawEvidenceResponse")] +#[pyclass(name = "RawEvidenceResponse", module = "registry_evidence_client")] struct RawEvidenceResponse { inner: RealRawEvidenceResponse, } @@ -229,7 +233,7 @@ struct RawEvidenceResponse { /// Unlike the two classes above, this is a terminal result nothing hands back /// into a later call, so it carries plain, eagerly converted data rather than /// protecting any interior state. -#[pyclass(name = "VerifiedEvidence")] +#[pyclass(name = "VerifiedEvidence", module = "registry_evidence_client")] struct VerifiedEvidence { /// The verified payload, as a plain Python object graph. #[pyo3(get)] @@ -272,7 +276,7 @@ fn verified_evidence_to_python( /// waits for the first to yield the runtime's single core rather than racing /// it, so two Python threads may safely call an async method on the same /// client at once. -#[pyclass(name = "EvidenceClient")] +#[pyclass(name = "EvidenceClient", module = "registry_evidence_client")] struct EvidenceClient { inner: RealEvidenceClient, runtime: tokio::runtime::Runtime, @@ -324,7 +328,8 @@ impl EvidenceClient { max_response_bytes, ) .map_err(|error| to_py_err(py, &map_config_error(&error)))?; - let inner = RealEvidenceClient::new(config) + let inner = py + .detach(|| RealEvidenceClient::new(config)) .map_err(|error| to_py_err(py, &map_client_error(&error)))?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -510,12 +515,18 @@ mod tests { /// `pyo3-0.29.1/src/panic.rs` (`PanicException` itself, at /// `pyo3::panic::PanicException`, not re-exported at the crate root). /// - /// This crate has exactly one latent panic to worry about: - /// `Ulid::new()` (used to generate a request nonce) reaches - /// `rand::rng()`, which panics when OS entropy is unavailable, a case the - /// wrapped crate deliberately leaves unguarded. No extra guard is added - /// here for it: this test proves the boundary already turns any such - /// panic into an ordinary Python exception instead of a process abort. + /// This crate has exactly one latent panic to worry about, and it is not + /// the client's own request nonce: that one is generated through + /// `getrandom::fill`, which reports entropy failure as an ordinary error + /// rather than panicking. The unguarded path is the private-key-JWT token + /// provider's own `jti` claim, generated with `Ulid::new()`, which reaches + /// `rand::rng()` and panics when OS entropy is unavailable, a case that + /// provider deliberately leaves unguarded. It is reachable only in a + /// deployment configured for private-key-JWT: a static-bearer deployment + /// never calls that provider, so it has no reachable panic at all. No + /// extra guard is added here for it: this test proves the boundary + /// already turns any such panic into an ordinary Python exception instead + /// of a process abort. /// /// The catch must be exercised from Python code, not from a Rust `call0`: /// `PyErr::take` (which every pyo3 call-from-Rust helper uses to read the diff --git a/crates/registry-evidence-client-py/tests/python/test_concurrency.py b/crates/registry-evidence-client-py/tests/python/test_concurrency.py index 0cadceefc..5dfb779b4 100644 --- a/crates/registry-evidence-client-py/tests/python/test_concurrency.py +++ b/crates/registry-evidence-client-py/tests/python/test_concurrency.py @@ -89,5 +89,82 @@ def call_discover() -> None: self.assertLess(elapsed, RESPONSE_DELAY_SECONDS * 1.5) +class ConstructionGilReleaseTest(unittest.TestCase): + """`EvidenceClient.__new__` also releases the GIL, for the same reason. + + Construction does no I/O (there is no server to race here at all: the + base URL below is never contacted), but it does real work in Rust before + returning: validating the configuration and building the TLS-capable HTTP + client, which loads the platform's native trust store. That work is + measurably slow enough, and this proves it overlaps across threads + instead of serializing while the GIL is released for it. + + Unlike the async methods above, overlapping construction does not land + close to the single-call cost: loading the native trust store twice at + once still costs more than doing it once, so two overlapping + constructions measure at roughly 1.5x the single-call cost on the + machines this was calibrated against, not roughly 1x. The threshold + below is set from that measured behavior, not copied from the roughly-1x + async case. + + The budget is calibrated against this machine's own speed rather than a + hard-coded duration, measured once at the start of this test run: a + fixed millisecond budget would either be flaky on a slow machine or too + loose on a fast one. + """ + + def test_two_concurrent_constructions_overlap_instead_of_serializing(self): + # Construction only validates and builds a client; it never connects, + # so this address does not need to be reachable. + base_url = "http://127.0.0.1:1" + + def build_client() -> None: + revc.EvidenceClient(base_url, fixtures.VALID_JWKS, "test-token") + + started_at = time.monotonic() + build_client() + baseline_elapsed = time.monotonic() - started_at + + barrier = threading.Barrier(2) + errors: list[BaseException] = [] + + def build_at_barrier() -> None: + try: + barrier.wait(timeout=JOIN_TIMEOUT_SECONDS) + build_client() + except BaseException as error: # noqa: BLE001 - captured, not swallowed + errors.append(error) + + threads = [threading.Thread(target=build_at_barrier) for _ in range(2)] + started_at = time.monotonic() + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=JOIN_TIMEOUT_SECONDS) + concurrent_elapsed = time.monotonic() - started_at + + for thread in threads: + self.assertFalse( + thread.is_alive(), + "a construction thread did not finish within the bounded timeout", + ) + self.assertEqual(errors, []) + + # Serialized (GIL held for the whole constructor), the pair costs + # roughly 2x the baseline. Overlapping (GIL released around the + # trust-store load), it still costs more than 1x, since loading that + # store twice at once genuinely costs more than doing it once, but + # measurement across repeated runs put it consistently around 1.5x, + # well clear of the roughly-2x serialized cost. 1.8x sits between the + # two with headroom on both sides: comfortably above ordinary + # overlapping jitter, and comfortably below what a regression to full + # serialization would measure. + self.assertLess( + concurrent_elapsed, + baseline_elapsed * 1.8, + f"baseline={baseline_elapsed:.4f}s concurrent={concurrent_elapsed:.4f}s", + ) + + if __name__ == "__main__": unittest.main() diff --git a/crates/registry-evidence-client-py/tests/python/test_reload.py b/crates/registry-evidence-client-py/tests/python/test_reload.py new file mode 100644 index 000000000..6635e0613 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_reload.py @@ -0,0 +1,59 @@ +"""Reloading the package after it has already been imported once. + +`importlib.reload` re-executes a module's body against its existing +namespace, unlike a first import, which starts from an empty one. The +package's own `__init__.py` removes the submodule's bare name +(`registry_evidence_client`) from that namespace after `import *` runs; that +removal must stay safe to repeat, since a reload is exactly a repeat. +""" + +from __future__ import annotations + +import importlib +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import registry_evidence_client as revc # noqa: E402 + +# The thirteen names `__init__.pyi` declares: the four plain classes, the +# base exception, and its eight subclasses. Kept in lockstep with +# `test_drift.py`'s own `PLAIN_CLASS_NAMES` and `EXCEPTION_SUBCLASS_NAMES`. +PUBLIC_SURFACE_NAMES = { + "EvidenceClient", + "PreparedEvidenceRequest", + "RawEvidenceResponse", + "VerifiedEvidence", + "EvidenceClientError", + "ConfigurationError", + "NonceError", + "TokenError", + "TransportError", + "DeniedError", + "NotAvailableError", + "ProtocolError", + "VerificationError", +} + + +class ReloadTest(unittest.TestCase): + def test_the_public_surface_survives_a_reload(self): + # The module-level import above already ran `__init__.py` once, which + # already removed the submodule's bare name from this namespace. This + # single `reload` call is already the second run of that removal, the + # one a non-idempotent `del` would raise `NameError` on. + importlib.reload(revc) + live_names = {name for name in dir(revc) if not name.startswith("_")} + self.assertEqual(live_names, PUBLIC_SURFACE_NAMES) + + +if __name__ == "__main__": + unittest.main() From a368fd655e74a84e19852cdddc53adc6d665b306 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 21:06:26 +0700 Subject: [PATCH 33/67] test(evidence): sharpen the Python binding's concurrency and canary coverage Two canary arrangements now trigger a refusal caused by the offending selector value and subject binding themselves, not just an unrelated missing-purpose refusal. The construction GIL-release test now counts a spinning observer thread's ticks instead of a wall-clock ratio, so it measures GIL availability directly rather than CPU parallelism that can serialize on a single core regardless of whether the GIL was held. The async concurrency test builds both clients before its timed region so the budget applies to the overlap it claims to measure, not to construction eating most of the intended headroom. The README's panic section now matches the corrected private-key-JWT jti panic path. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client-py/README.md | 15 +- .../src/convert.rs | 42 +++++ .../tests/python/test_concurrency.py | 143 +++++++++++------- 3 files changed, 140 insertions(+), 60 deletions(-) diff --git a/crates/registry-evidence-client-py/README.md b/crates/registry-evidence-client-py/README.md index 28475d258..3bc823228 100644 --- a/crates/registry-evidence-client-py/README.md +++ b/crates/registry-evidence-client-py/README.md @@ -74,11 +74,16 @@ trampoline (behind `#[pymethods]`, `#[pyfunction]`, and `#[pymodule]`) wraps the call in `std::panic::catch_unwind` and translates a caught panic into PyO3's own `PanicException`, before this crate adds anything of its own. See `pyo3-0.29.1/src/impl_/trampoline.rs` (the `trampoline` function) in the -vendored source for the exact mechanism. This crate does not add a second -guard on top; it relies on PyO3's own. One latent panic path exists upstream, -unguarded on purpose: `Ulid::new()`'s call into `rand::rng()` can panic if the -platform RNG is unavailable. A caller who calls a client method from Python -will see that surface as `PanicException`, not a process abort. +vendored source for the exact mechanism. The client's own request nonce is +generated through `getrandom::fill`, which reports entropy failure as an +ordinary error and cannot panic. The one latent panic path is upstream and +left unguarded on purpose: the private-key-JWT token provider's `jti` claim, +generated with `Ulid::new()`, reaches `rand::rng()` and panics if OS entropy +is unavailable. It is reachable only in a deployment configured for +private-key-JWT; a static-bearer deployment has no reachable panic at all. +This crate adds no guard of its own for it: the trampoline above already +turns any such panic into an ordinary Python exception rather than a process +abort. ### The `unsafe_code` lint diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index f35ceb750..92d855290 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -1234,6 +1234,16 @@ mod tests { /// `RawEvidenceResponse`), so it has no case here; the wrapped crate's own /// test already covers it. /// + /// Six arrangements. In four of them, the canary is the very value the + /// refusing code is judging when it refuses: a bearer credential, a + /// signing key's `d` member, an array selector value, and a bare + /// `subject_expectations` string that is not `"accept_first_use"`. In the + /// remaining two, the canary instead sits in a well-typed selector value + /// or pinned subject binding while an unrelated missing-`purpose` + /// refusal fires; those two prove only that an unrelated error does not + /// sweep up a value merely sitting in memory, a narrower property than + /// the first four but still worth keeping. + /// /// Each arrangement first asserts on the specific error it produces, not /// only on the canary's absence: if a step refused for an unrelated /// reason instead of carrying the canary to the intended place, that @@ -1337,6 +1347,38 @@ mod tests { let error = spec_from_json(&spec).expect_err("the missing `purpose` is refused"); assert_eq!(error, ConversionError::new("`purpose` must be a string")); assert_canary_absent(&map_conversion_error(&error)); + + // A selector value whose offending input is the canary: an array is + // not one of the permitted selector value shapes, so + // `selector_value_from_json` refuses it with its own fixed message. + // `purpose` is left in place, so this refusal, unlike the two above, + // is caused by the canary itself rather than merely coinciding with + // one raised for an unrelated reason. + let mut spec = valid_spec_json(); + spec["subjects"][0]["selector_values"] = + serde_json::json!({ "record_reference": [CANARY] }); + let error = spec_from_json(&spec).expect_err("an array selector value is refused"); + assert_eq!( + error, + ConversionError::new("a selector value must be a string, an integer, or a boolean") + ); + assert_canary_absent(&map_conversion_error(&error)); + + // A `subject_expectations` value whose offending input is the + // canary: a bare string that is not `"accept_first_use"` is refused + // by `subject_expectations_from_json`'s own fixed message. `purpose` + // is again left in place, so the canary itself causes this refusal. + let mut spec = valid_spec_json(); + spec["subject_expectations"] = Value::String(CANARY.to_owned()); + let error = + spec_from_json(&spec).expect_err("a bare non-accept_first_use string is refused"); + assert_eq!( + error, + ConversionError::new( + "`subject_expectations` must be \"accept_first_use\" or a sequence of {\"role\", \"binding\"} mappings" + ) + ); + assert_canary_absent(&map_conversion_error(&error)); } #[test] diff --git a/crates/registry-evidence-client-py/tests/python/test_concurrency.py b/crates/registry-evidence-client-py/tests/python/test_concurrency.py index 5dfb779b4..dde02c158 100644 --- a/crates/registry-evidence-client-py/tests/python/test_concurrency.py +++ b/crates/registry-evidence-client-py/tests/python/test_concurrency.py @@ -57,17 +57,24 @@ def test_two_concurrent_calls_overlap_instead_of_serializing(self): barrier = threading.Barrier(2) errors: list[BaseException] = [] - def call_discover() -> None: + # Built on the main thread, before the timed region below, so the + # elapsed time it measures is the two `discover()` calls themselves, + # not construction plus those calls. + clients = [ + revc.EvidenceClient(server.base_url, fixtures.VALID_JWKS, "test-token") + for _ in range(2) + ] + + def call_discover(client: revc.EvidenceClient) -> None: try: - client = revc.EvidenceClient( - server.base_url, fixtures.VALID_JWKS, "test-token" - ) barrier.wait(timeout=JOIN_TIMEOUT_SECONDS) client.discover() except BaseException as error: # noqa: BLE001 - captured, not swallowed errors.append(error) - threads = [threading.Thread(target=call_discover) for _ in range(2)] + threads = [ + threading.Thread(target=call_discover, args=(client,)) for client in clients + ] started_at = time.monotonic() for thread in threads: thread.start() @@ -90,30 +97,31 @@ def call_discover() -> None: class ConstructionGilReleaseTest(unittest.TestCase): - """`EvidenceClient.__new__` also releases the GIL, for the same reason. + """`EvidenceClient.__new__` releases the GIL around its Rust-side work. Construction does no I/O (there is no server to race here at all: the base URL below is never contacted), but it does real work in Rust before returning: validating the configuration and building the TLS-capable HTTP - client, which loads the platform's native trust store. That work is - measurably slow enough, and this proves it overlaps across threads - instead of serializing while the GIL is released for it. - - Unlike the async methods above, overlapping construction does not land - close to the single-call cost: loading the native trust store twice at - once still costs more than doing it once, so two overlapping - constructions measure at roughly 1.5x the single-call cost on the - machines this was calibrated against, not roughly 1x. The threshold - below is set from that measured behavior, not copied from the roughly-1x - async case. - - The budget is calibrated against this machine's own speed rather than a - hard-coded duration, measured once at the start of this test run: a - fixed millisecond budget would either be flaky on a slow machine or too - loose on a fast one. + client, which loads the platform's native trust store. This proves the + GIL is available to other Python threads for the duration of that work, + with a daemon thread that spins incrementing a counter in a plain Python + loop until told to stop. Each loop iteration can only execute while the + spinning thread holds the GIL, so the final count is a direct measure of + how much GIL time that thread was given, not of CPU parallelism: unlike + timing two overlapping constructions against each other, it cannot be + confounded by both constructions instead serializing on a single CPU + core. + + A control window, the observer running while the main thread sleeps + (which releases the GIL), calibrates this machine's tick rate with + nothing competing. The same observer, fresh, then runs across one + construction on the main thread. The construction window's count is + compared against the control count as a fraction, not against an + absolute number, so the assertion holds regardless of how fast any given + machine ticks. """ - def test_two_concurrent_constructions_overlap_instead_of_serializing(self): + def test_construction_releases_the_gil(self): # Construction only validates and builds a client; it never connects, # so this address does not need to be reachable. base_url = "http://127.0.0.1:1" @@ -121,48 +129,73 @@ def test_two_concurrent_constructions_overlap_instead_of_serializing(self): def build_client() -> None: revc.EvidenceClient(base_url, fixtures.VALID_JWKS, "test-token") - started_at = time.monotonic() - build_client() - baseline_elapsed = time.monotonic() - started_at - - barrier = threading.Barrier(2) - errors: list[BaseException] = [] - - def build_at_barrier() -> None: + def spin_observer( + counter: list[int], + stop_event: threading.Event, + errors: list[BaseException], + ) -> None: try: - barrier.wait(timeout=JOIN_TIMEOUT_SECONDS) - build_client() + while not stop_event.is_set(): + counter[0] += 1 except BaseException as error: # noqa: BLE001 - captured, not swallowed errors.append(error) - threads = [threading.Thread(target=build_at_barrier) for _ in range(2)] - started_at = time.monotonic() - for thread in threads: + def start_observer(): + counter = [0] + stop_event = threading.Event() + errors: list[BaseException] = [] + thread = threading.Thread( + target=spin_observer, args=(counter, stop_event, errors), daemon=True + ) thread.start() - for thread in threads: - thread.join(timeout=JOIN_TIMEOUT_SECONDS) - concurrent_elapsed = time.monotonic() - started_at + return thread, counter, stop_event, errors - for thread in threads: + def stop_observer(thread, stop_event, errors) -> None: + stop_event.set() + thread.join(timeout=JOIN_TIMEOUT_SECONDS) self.assertFalse( thread.is_alive(), - "a construction thread did not finish within the bounded timeout", + "the GIL observer thread did not stop within the bounded timeout", ) - self.assertEqual(errors, []) + self.assertEqual(errors, []) - # Serialized (GIL held for the whole constructor), the pair costs - # roughly 2x the baseline. Overlapping (GIL released around the - # trust-store load), it still costs more than 1x, since loading that - # store twice at once genuinely costs more than doing it once, but - # measurement across repeated runs put it consistently around 1.5x, - # well clear of the roughly-2x serialized cost. 1.8x sits between the - # two with headroom on both sides: comfortably above ordinary - # overlapping jitter, and comfortably below what a regression to full - # serialization would measure. - self.assertLess( - concurrent_elapsed, - baseline_elapsed * 1.8, - f"baseline={baseline_elapsed:.4f}s concurrent={concurrent_elapsed:.4f}s", + # A throwaway construction times roughly how long the real + # measurement below will take, so the control window spins the + # observer for a comparable duration. + started_at = time.monotonic() + build_client() + approximate_construction_seconds = time.monotonic() - started_at + + control_thread, control_counter, control_stop, control_errors = start_observer() + try: + time.sleep(approximate_construction_seconds) + finally: + stop_observer(control_thread, control_stop, control_errors) + control_ticks = control_counter[0] + + ( + construction_thread, + construction_counter, + construction_stop, + construction_errors, + ) = start_observer() + try: + build_client() + finally: + stop_observer(construction_thread, construction_stop, construction_errors) + construction_ticks = construction_counter[0] + + # A released GIL lets the observer tick at close to the control + # rate. A held GIL still lets some ticks through even for a call + # that never touches the interpreter, but nowhere near half of the + # control rate on this platform. A floor of half the control count + # sits with clear room on both sides of that gap, so the assertion + # discriminates a released GIL from a held one without depending on + # an exact percentage. + self.assertGreaterEqual( + construction_ticks, + control_ticks * 0.5, + f"control_ticks={control_ticks} construction_ticks={construction_ticks}", ) From d5a6c831df627d2d2f51578713f6c536b1941fb6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 22:05:35 +0700 Subject: [PATCH 34/67] test(evidence): tighten GIL-release measurement window Snapshot the observer's tick count immediately around each timed call instead of over the observer thread's whole lifetime, so ticks from starting or stopping it never leak into the control or construction counts. Update the docstring and assertion comment to describe the delta-snapshot measurement and the one unavoidable post-call timeslice it cannot eliminate, rather than the observer's prior lifetime count. Signed-off-by: Jeremi Joslin --- .../tests/python/test_concurrency.py | 95 +++++++++++++++---- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/crates/registry-evidence-client-py/tests/python/test_concurrency.py b/crates/registry-evidence-client-py/tests/python/test_concurrency.py index dde02c158..442b8ec35 100644 --- a/crates/registry-evidence-client-py/tests/python/test_concurrency.py +++ b/crates/registry-evidence-client-py/tests/python/test_concurrency.py @@ -105,20 +105,37 @@ class ConstructionGilReleaseTest(unittest.TestCase): client, which loads the platform's native trust store. This proves the GIL is available to other Python threads for the duration of that work, with a daemon thread that spins incrementing a counter in a plain Python - loop until told to stop. Each loop iteration can only execute while the - spinning thread holds the GIL, so the final count is a direct measure of - how much GIL time that thread was given, not of CPU parallelism: unlike - timing two overlapping constructions against each other, it cannot be - confounded by both constructions instead serializing on a single CPU - core. + loop until told to stop. The observer can only advance while it holds + the GIL, so its tick count during a call is a direct measure of how much + GIL time that call gave up: a constructor that keeps the GIL for its + Rust work starves the observer, and one that releases it does not. + Measuring it this way, rather than timing two overlapping constructions + against each other, also means it cannot be confounded by both + constructions instead serializing on a single CPU core. A control window, the observer running while the main thread sleeps (which releases the GIL), calibrates this machine's tick rate with nothing competing. The same observer, fresh, then runs across one - construction on the main thread. The construction window's count is - compared against the control count as a fraction, not against an - absolute number, so the assertion holds regardless of how fast any given - machine ticks. + construction on the main thread. Both windows are measured the same way: + a tick snapshot taken immediately before and after the timed call, never + the observer's count over its whole lifetime, so ticks from starting or + stopping the observer thread never enter either number. The construction + window's count is compared against the control count as a fraction, not + against an absolute number, so the assertion holds regardless of how + fast any given machine ticks. + + The switch interval is left at its default rather than raised. A + construction call that runs for tens of milliseconds already leaves the + observer waiting well past the default interval, so the interpreter + hands it one timeslice at the boundary right after the call returns no + matter the interval's size; that slice costs at most one interval's + worth of ticks, small and roughly constant next to the construction + time it is measured against, so it cannot make a held GIL look released. + Raising the interval would not shrink that slice away, because the + observer never yields the GIL voluntarily: whichever thread holds the + GIL when the interval next elapses is the only one that can be made to + give it up, so a bigger interval only makes that one unavoidable handoff + bigger too. Leaving it at the default keeps that handoff small instead. """ def test_construction_releases_the_gil(self): @@ -159,6 +176,37 @@ def stop_observer(thread, stop_event, errors) -> None: ) self.assertEqual(errors, []) + def wait_until_ticking(thread, counter, errors) -> None: + # `threading.Thread.start()` blocks on the new thread's startup + # event, and waiting on an event releases the GIL, so the + # observer can already be ticking before this is even called. + # Waiting here for its first tick keeps that startup handoff + # out of the measured window entirely, rather than measuring + # from a snapshot that might land before the observer has run + # at all for reasons that have nothing to do with the call + # being timed. + deadline = time.monotonic() + JOIN_TIMEOUT_SECONDS + while counter[0] == 0: + self.assertTrue( + thread.is_alive(), + f"the GIL observer thread exited before ticking: {errors}", + ) + self.assertLessEqual( + time.monotonic(), + deadline, + "the GIL observer never ticked within the bounded settle timeout", + ) + time.sleep(0) + + def measure_ticks(counter, run) -> int: + # The snapshots go immediately around `run`, not over the + # observer's whole lifetime, so ticks from starting or stopping + # the observer thread never enter the delta: only ticks that + # land while `run` itself is executing do. + start_ticks = counter[0] + run() + return counter[0] - start_ticks + # A throwaway construction times roughly how long the real # measurement below will take, so the control window spins the # observer for a comparable duration. @@ -168,10 +216,12 @@ def stop_observer(thread, stop_event, errors) -> None: control_thread, control_counter, control_stop, control_errors = start_observer() try: - time.sleep(approximate_construction_seconds) + wait_until_ticking(control_thread, control_counter, control_errors) + control_ticks = measure_ticks( + control_counter, lambda: time.sleep(approximate_construction_seconds) + ) finally: stop_observer(control_thread, control_stop, control_errors) - control_ticks = control_counter[0] ( construction_thread, @@ -180,18 +230,23 @@ def stop_observer(thread, stop_event, errors) -> None: construction_errors, ) = start_observer() try: - build_client() + wait_until_ticking( + construction_thread, construction_counter, construction_errors + ) + construction_ticks = measure_ticks(construction_counter, build_client) finally: stop_observer(construction_thread, construction_stop, construction_errors) - construction_ticks = construction_counter[0] # A released GIL lets the observer tick at close to the control - # rate. A held GIL still lets some ticks through even for a call - # that never touches the interpreter, but nowhere near half of the - # control rate on this platform. A floor of half the control count - # sits with clear room on both sides of that gap, so the assertion - # discriminates a released GIL from a held one without depending on - # an exact percentage. + # rate. A held GIL keeps the observer from advancing for almost all + # of the call's duration, since it can only advance while it holds + # the GIL itself; the one unavoidable timeslice at the boundary + # right after the call returns costs at most one switch interval's + # worth of ticks, small next to a call that runs for tens of + # milliseconds. A floor of half the control count sits with clear + # room on both sides of that gap, so the assertion discriminates a + # released GIL from a held one without depending on an exact + # percentage. self.assertGreaterEqual( construction_ticks, control_ticks * 0.5, From 96aa1270e4bda10d8a10f150d2052ff529d70958 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 22:13:30 +0700 Subject: [PATCH 35/67] test(evidence): set the Mint audit rotation threshold in the client fixture Mint's audit configuration requires a per-segment rotation threshold, so the fixture that starts a real Mint beside the Evidence client has to supply one or the binary refuses to start before the test can reach its assertions. The value matches Mint's own documented example. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client/tests/against_a_real_deployment.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 112057504..fdd9bc326 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -1169,6 +1169,7 @@ signing: activeKeyFile: secrets/signing.jwk audit: path: audit/decisions.jsonl + maximumFileBytes: 1073741824 hashKeyFile: secrets/audit-hash-key hashKeyVersion: 1 accessTokens: From 7ed38e06b2b915e08b61e1951bbe17f6b52891a3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 22:14:41 +0700 Subject: [PATCH 36/67] chore(evidence): align the Node binding's package version with the workspace The workspace publishes every crate at one version, and the Node binding's package metadata carries that number separately from Cargo's. `index.js` is regenerated by `npm run build`, which embeds the version in its loader guards. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client-node/index.js | 108 +++++++++--------- .../package-lock.json | 4 +- .../package.json | 2 +- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/crates/registry-evidence-client-node/index.js b/crates/registry-evidence-client-node/index.js index b36861995..9e865bdd7 100644 --- a/crates/registry-evidence-client-node/index.js +++ b/crates/registry-evidence-client-node/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-android-arm64') const bindingPackageVersion = require('@registrystack/evidence-client-android-arm64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-android-arm-eabi') const bindingPackageVersion = require('@registrystack/evidence-client-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-win32-x64-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-win32-x64-msvc') const bindingPackageVersion = require('@registrystack/evidence-client-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-win32-ia32-msvc') const bindingPackageVersion = require('@registrystack/evidence-client-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-win32-arm64-msvc') const bindingPackageVersion = require('@registrystack/evidence-client-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-darwin-universal') const bindingPackageVersion = require('@registrystack/evidence-client-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-darwin-x64') const bindingPackageVersion = require('@registrystack/evidence-client-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-darwin-arm64') const bindingPackageVersion = require('@registrystack/evidence-client-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-freebsd-x64') const bindingPackageVersion = require('@registrystack/evidence-client-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-freebsd-arm64') const bindingPackageVersion = require('@registrystack/evidence-client-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-x64-musl') const bindingPackageVersion = require('@registrystack/evidence-client-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-x64-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-arm64-musl') const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-arm64-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-arm-musleabihf') const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-arm-gnueabihf') const bindingPackageVersion = require('@registrystack/evidence-client-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-loong64-musl') const bindingPackageVersion = require('@registrystack/evidence-client-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-loong64-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-riscv64-musl') const bindingPackageVersion = require('@registrystack/evidence-client-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-riscv64-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-ppc64-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-linux-s390x-gnu') const bindingPackageVersion = require('@registrystack/evidence-client-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-openharmony-arm64') const bindingPackageVersion = require('@registrystack/evidence-client-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-openharmony-x64') const bindingPackageVersion = require('@registrystack/evidence-client-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@registrystack/evidence-client-openharmony-arm') const bindingPackageVersion = require('@registrystack/evidence-client-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.16.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -648,8 +648,8 @@ if (!nativeBinding || forceWasi) { if (!candidateFailed) { if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { const bindingPackageVersion = require('@registrystack/evidence-client-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '0.16.3') { - throw new Error(`WASI binding package version mismatch, expected 0.16.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.17.0') { + throw new Error(`WASI binding package version mismatch, expected 0.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } } wasiBinding = require('@registrystack/evidence-client-wasm32-wasi') diff --git a/crates/registry-evidence-client-node/package-lock.json b/crates/registry-evidence-client-node/package-lock.json index 5837d8429..dc3f8669e 100644 --- a/crates/registry-evidence-client-node/package-lock.json +++ b/crates/registry-evidence-client-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@registrystack/evidence-client", - "version": "0.16.3", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@registrystack/evidence-client", - "version": "0.16.3", + "version": "0.17.0", "license": "Apache-2.0", "devDependencies": { "@napi-rs/cli": "3.8.2" diff --git a/crates/registry-evidence-client-node/package.json b/crates/registry-evidence-client-node/package.json index 5155681f3..42f5dad43 100644 --- a/crates/registry-evidence-client-node/package.json +++ b/crates/registry-evidence-client-node/package.json @@ -1,6 +1,6 @@ { "name": "@registrystack/evidence-client", - "version": "0.16.3", + "version": "0.17.0", "description": "Node.js binding for the Evidence relying-party client, via napi-rs.", "license": "Apache-2.0", "repository": { From 30e6cb09738287c36a88fbaa7d393ecac324677a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:13:34 +0700 Subject: [PATCH 37/67] fix(evidence): keep subject bindings out of Debug renderings A subject binding is a pseudonymous per-subject identifier, so it must not reach a Debug rendering. Making ExpectedSubjectDocument public SDK surface made one renderable by any relying party that formats a retained policy, and ExpectedSubject carried the same derive. Both types now render the role and elide the binding, following the hand-written Debug that SubjectExpectations already carries. The policy document keeps its derive and inherits the elision through the subject type, which its own test pins. Signed-off-by: Jeremi Joslin --- .../src/verifier.rs | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence-verifier/src/verifier.rs b/crates/registry-evidence-verifier/src/verifier.rs index 277d59b3d..84d6e74c2 100644 --- a/crates/registry-evidence-verifier/src/verifier.rs +++ b/crates/registry-evidence-verifier/src/verifier.rs @@ -87,13 +87,24 @@ pub struct EvidenceVerificationPolicyDocument { pub clock_skew_seconds: u64, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpectedSubjectDocument { pub role: String, pub binding: String, } +impl std::fmt::Debug for ExpectedSubjectDocument { + /// A binding is a pseudonymous per-subject identifier, so only the role is + /// rendered. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExpectedSubjectDocument") + .field("role", &self.role) + .finish_non_exhaustive() + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpectedOutputDocument { @@ -276,12 +287,23 @@ fn expected_form_of(value: &crate::model::PublicValue) -> ExpectedValueForm { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ExpectedSubject { pub role: String, pub binding: String, } +impl std::fmt::Debug for ExpectedSubject { + /// A binding is a pseudonymous per-subject identifier, so only the role is + /// rendered. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExpectedSubject") + .field("role", &self.role) + .finish_non_exhaustive() + } +} + #[derive(Debug, Clone)] pub struct ExpectedOutput { pub concept: String, @@ -1482,6 +1504,57 @@ mod tests { } } + const DEBUG_BINDING_CANARY: &str = "verifier-debug-binding-canary-x7q"; + + #[test] + fn expected_subject_document_debug_never_carries_its_binding() { + let subject = ExpectedSubjectDocument { + role: "candidate-parent".to_string(), + binding: DEBUG_BINDING_CANARY.to_string(), + }; + let rendered = format!("{subject:?}"); + assert!(!rendered.contains(DEBUG_BINDING_CANARY), "{rendered}"); + assert!(rendered.contains("candidate-parent"), "{rendered}"); + } + + #[test] + fn expected_subject_debug_never_carries_its_binding() { + let subject = ExpectedSubject { + role: "candidate-parent".to_string(), + binding: DEBUG_BINDING_CANARY.to_string(), + }; + let rendered = format!("{subject:?}"); + assert!(!rendered.contains(DEBUG_BINDING_CANARY), "{rendered}"); + assert!(rendered.contains("candidate-parent"), "{rendered}"); + } + + /// The policy document derives its `Debug`, so this proves the derive + /// delegates to `ExpectedSubjectDocument`'s own redaction rather than + /// relying on a second hand-written impl here. + #[test] + fn policy_document_debug_never_carries_a_subject_binding_through_derive() { + let policy = EvidenceVerificationPolicyDocument { + expected_assurance_profile: AssuranceProfile::EvidenceGrade, + issued_by: "urn:example:issuer".to_string(), + provided_by: "urn:example:provider".to_string(), + requirement: "urn:example:requirement:v1".to_string(), + evidence_type: "urn:example:type:v1".to_string(), + purpose: "casework".to_string(), + audience: "urn:example:audience".to_string(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + request_nonce: FIXTURE_NONCE.to_string(), + expected_subjects: vec![ExpectedSubjectDocument { + role: "candidate-parent".to_string(), + binding: DEBUG_BINDING_CANARY.to_string(), + }], + expected_outputs: Vec::new(), + maximum_assertion_lifetime_seconds: 48 * 60 * 60, + clock_skew_seconds: 30, + }; + let rendered = format!("{policy:?}"); + assert!(!rendered.contains(DEBUG_BINDING_CANARY), "{rendered}"); + } + #[tokio::test] async fn expected_output_contract_is_exact_after_signature_verification() { let (jws, jwks, policy) = signed_fixture().await; From 2833fc370823262d6b7f69b22d458fe868151312 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:13:35 +0700 Subject: [PATCH 38/67] fix(evidence): withhold base URL userinfo from the client config Debug The config's hand-written Debug promises to withhold the credential source, but it rendered the base URL verbatim. validate() refuses a base URL carrying credentials and runs inside EvidenceClient::new, so a config formatted before construction rendered the password. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/config.rs | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index 3a3568056..358774c2e 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -4,7 +4,7 @@ //! integrator, out of band. The client never replaces it with keys a response //! or a discovery document named. -use std::{fmt, sync::Arc, time::Duration}; +use std::{borrow::Cow, fmt, sync::Arc, time::Duration}; use registry_evidence_verifier::model::JwksDocument; use registry_platform_httputil::DEFAULT_OUTBOUND_CONNECT_TIMEOUT; @@ -169,13 +169,33 @@ impl EvidenceClientConfig { } } +/// The base URL with any userinfo removed. +/// +/// [`EvidenceClientConfig::validate`] refuses a base URL carrying credentials, +/// but it runs inside `EvidenceClient::new`, so the rendering cannot rely on +/// having been reached after construction. +fn base_url_without_userinfo(base_url: &Url) -> Cow<'_, str> { + if base_url.username().is_empty() && base_url.password().is_none() { + return Cow::Borrowed(base_url.as_str()); + } + let mut stripped = base_url.clone(); + // Both setters refuse only a URL that cannot carry userinfo at all, and this + // point is reached only for a URL that carries some, so neither can refuse + // here. A refusal withholds the whole URL rather than rendering a credential. + if stripped.set_username("").is_err() || stripped.set_password(None).is_err() { + return Cow::Borrowed(""); + } + Cow::Owned(stripped.into()) +} + impl fmt::Debug for EvidenceClientConfig { /// The key set, the credential source, and the pinned certificate material - /// are all withheld. Only the operational choices are rendered. + /// are all withheld, as is any userinfo in the base URL. Only the + /// operational choices are rendered. fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("EvidenceClientConfig") - .field("base_url", &self.base_url.as_str()) + .field("base_url", &base_url_without_userinfo(&self.base_url)) .field("request_timeout", &self.request_timeout) .field("connect_timeout", &self.connect_timeout) .field("user_agent", &self.user_agent) @@ -352,4 +372,16 @@ mod tests { "{rendered}" ); } + + #[test] + fn debug_output_withholds_userinfo_the_caller_put_in_the_base_url() { + let config = config("https://operator:canary-secret@evidence.example.org"); + let rendered = format!("{config:?}"); + assert!(!rendered.contains("canary-secret"), "{rendered}"); + assert!(!rendered.contains("operator"), "{rendered}"); + assert!( + rendered.contains("https://evidence.example.org/"), + "{rendered}" + ); + } } From 76f3bfaec27fcf48b3e32e05ab5eaefda020b503 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:13:35 +0700 Subject: [PATCH 39/67] fix(evidence): refuse an unusable asOfMillis through the Node error envelope verifyAsOf built its refusal with NapiError::from_reason, so it reached the caller without the kind every other mapped failure carries, and the f64 to i64 cast saturated ahead of that check: NaN became the Unix epoch and verified against an instant the caller never asked for. A conversion helper mirrors the Python binding's own, refusing non-finite and out-of-range values as a configuration failure through the JSON envelope. client.d.ts's status line gains the token failure that convert.rs sets it on, and the nonce arm gains the comment its Python counterpart carries. Signed-off-by: Jeremi Joslin --- .../__test__/errors.test.js | 40 +++++++++++++++++- .../registry-evidence-client-node/client.d.ts | 3 +- .../src/convert.rs | 41 +++++++++++++++++++ .../registry-evidence-client-node/src/lib.rs | 10 ++--- 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/registry-evidence-client-node/__test__/errors.test.js b/crates/registry-evidence-client-node/__test__/errors.test.js index 0cfb2159d..53da02190 100644 --- a/crates/registry-evidence-client-node/__test__/errors.test.js +++ b/crates/registry-evidence-client-node/__test__/errors.test.js @@ -5,7 +5,7 @@ const { test } = require('node:test'); const { EvidenceClient, EvidenceClientError } = require('..'); const { startStubServer } = require('./helpers/stub-server'); -const { requestSpec } = require('./helpers/live-signing'); +const { generateSigningKey, signEvidence, requestSpec, evidenceFor } = require('./helpers/live-signing'); const DUMMY_JWKS = { keys: [ @@ -184,3 +184,41 @@ test('a response over maxResponseBytes is refused as a transport failure, not a await stub.close(); } }); + +test('verifyAsOf refuses a non-finite or unrepresentable asOfMillis as a configuration failure', async () => { + const signingKey = generateSigningKey('as-of-millis-key'); + const spec = requestSpec(); + const stub = await startStubServer({ + 'POST /v1/evidence': (req, res, body) => { + const requestBody = JSON.parse(body.toString('utf8')); + const evidence = evidenceFor(spec, requestBody.requestNonce); + const jws = signEvidence(evidence, signingKey); + res.writeHead(200, { 'content-type': 'application/jose+json' }); + res.end(JSON.stringify(jws)); + }, + }); + + try { + const client = new EvidenceClient({ + baseUrl: stub.baseUrl, + trustedJwks: signingKey.jwks, + token: { static: 'as-of-millis-token' }, + }); + const prepared = client.prepare(spec); + const response = await client.send(prepared); + + for (const asOfMillis of [NaN, Infinity, Number.MAX_VALUE]) { + assert.throws( + () => client.verifyAsOf(prepared, response, asOfMillis), + (error) => { + assert.ok(error instanceof EvidenceClientError); + assert.equal(error.kind, 'configuration'); + return true; + }, + `asOfMillis ${asOfMillis} was accepted`, + ); + } + } finally { + await stub.close(); + } +}); diff --git a/crates/registry-evidence-client-node/client.d.ts b/crates/registry-evidence-client-node/client.d.ts index 7c3c05973..e65fda03b 100644 --- a/crates/registry-evidence-client-node/client.d.ts +++ b/crates/registry-evidence-client-node/client.d.ts @@ -12,7 +12,8 @@ export * from './index' * * `kind` is always present. The rest are present only when the underlying * failure carries them: - * - `status`: `denied` and `protocol` + * - `status`: `denied`, `protocol`, and any `token` failure whose `tokenKind` + * is `protocol` * - `code`: `denied`, `protocol` (optional), `verification`, and any `token` * failure whose `tokenKind` is `refused` * - `operation`: `denied`, `not_available`, `protocol` (all optional) diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index 0887588ba..251b2546e 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -9,6 +9,7 @@ use std::{fmt, sync::Arc, time::Duration}; +use chrono::{DateTime, Utc}; use registry_evidence_client::{ AssuranceProfile, Evidence, EvidenceClientConfig, EvidenceClientError, EvidenceRequestSpec, ExpectedOutputDocument, ExpectedSubjectDocument, JwksDocument, PrivateKeyJwt, @@ -119,6 +120,19 @@ fn parse_url(value: &str, what: &str) -> Result { Url::parse(value).map_err(|_| ConversionError::new(format!("{what} must be a valid URL"))) } +/// Turn `verify_as_of`'s caller-supplied UNIX timestamp (milliseconds, as +/// `asOfMillis`) into the instant +/// [`registry_evidence_client::EvidenceClient::verify_as_of`] takes. +pub fn datetime_from_unix_millis(millis: f64) -> Result, ConversionError> { + if !millis.is_finite() { + return Err(ConversionError::new( + "`asOfMillis` must be a finite number of milliseconds since the UNIX epoch", + )); + } + DateTime::from_timestamp_millis(millis as i64) + .ok_or_else(|| ConversionError::new("`asOfMillis` is not a representable instant")) +} + /// The three scalar shapes a selector value may take on the wire, read off a /// JS value. /// @@ -470,6 +484,10 @@ pub fn map_client_error(error: &EvidenceClientError) -> Value { fields.insert("message".to_owned(), Value::String(error.to_string())); match error { + // Deliberately no `nonceKind` field: `NonceError::NotCanonical` is + // constructed only inside `RequestNonce::parse`'s own unit tests, so + // this crate's production path can only ever fail here with + // `NonceError::Entropy`, which carries nothing further to report. EvidenceClientError::Configuration { .. } | EvidenceClientError::Nonce(_) => {} EvidenceClientError::Token(token_error) => insert_token_fields(&mut fields, token_error), EvidenceClientError::Transport { kind } => { @@ -567,6 +585,29 @@ mod tests { use super::*; + // --- datetime_from_unix_millis --- + + #[test] + fn a_non_finite_millis_value_is_refused() { + for millis in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!( + datetime_from_unix_millis(millis).is_err(), + "{millis} was accepted" + ); + } + } + + #[test] + fn a_millis_value_outside_the_representable_range_is_refused() { + assert!(datetime_from_unix_millis(f64::MAX).is_err()); + } + + #[test] + fn a_millis_value_converts_to_the_expected_instant() { + let parsed = datetime_from_unix_millis(1_000.0).expect("the value converts"); + assert_eq!(parsed.timestamp(), 1); + } + // --- selector_value_from_json --- #[test] diff --git a/crates/registry-evidence-client-node/src/lib.rs b/crates/registry-evidence-client-node/src/lib.rs index 4e801563c..f149c378b 100644 --- a/crates/registry-evidence-client-node/src/lib.rs +++ b/crates/registry-evidence-client-node/src/lib.rs @@ -47,8 +47,8 @@ use registry_evidence_client::{ }; use convert::{ - config_from_json, evidence_to_json, map_client_error, map_config_error, map_conversion_error, - spec_from_json, subject_expectations_to_json, + config_from_json, datetime_from_unix_millis, evidence_to_json, map_client_error, + map_config_error, map_conversion_error, spec_from_json, subject_expectations_to_json, }; /// Every mapped failure (see `convert::map_client_error` and friends) carries @@ -376,10 +376,8 @@ impl EvidenceClient { as_of_millis: f64, ) -> Result { catch_panic("verifying a response as of an instant", || { - let millis = as_of_millis as i64; - let now = chrono::DateTime::from_timestamp_millis(millis).ok_or_else(|| { - NapiError::from_reason("`asOfMillis` is not a representable instant".to_owned()) - })?; + let now = datetime_from_unix_millis(as_of_millis) + .map_err(|error| to_napi_error(map_conversion_error(&error)))?; let verified = self .inner .verify_as_of(&prepared.inner, &response.inner, now) From 4fa92a041bfad69fdf3dbd58297321a9eb2d6f28 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:20:35 +0700 Subject: [PATCH 40/67] fix(evidence): correct the Python binding's error and panic documentation `transport_kind` is set on a `TokenError` whose `token_kind` is `transport` as well as on a `TransportError`, so the README and the comment above `create_exception!` both stated a rule the conversion layer does not follow, and disagreed with the committed `.pyi` and the Node binding's `client.d.ts`. The panic-path passages read as absolute while `to_py_err`'s `set_attr!` calls can panic under allocation failure. Conversion coverage now matches the Node binding's: all six `TokenError` sub-kinds, and a verification failure carrying the verifier's kind as its `code`. A test pins each of the eight known kind strings to its specific exception class, so renaming a kind fails rather than quietly degrading to the base class through the deliberate catch-all. The `DYLD_LIBRARY_PATH` example loses a developer's home directory and a pinned interpreter patch version. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client-py/README.md | 35 ++++++----- .../src/convert.rs | 49 +++++++++++++-- crates/registry-evidence-client-py/src/lib.rs | 62 ++++++++++++++----- 3 files changed, 111 insertions(+), 35 deletions(-) diff --git a/crates/registry-evidence-client-py/README.md b/crates/registry-evidence-client-py/README.md index 3bc823228..b82c6d828 100644 --- a/crates/registry-evidence-client-py/README.md +++ b/crates/registry-evidence-client-py/README.md @@ -6,10 +6,10 @@ preparation, sending, and verification) is the wrapped Rust crate's own; this crate is a thin `#[pymodule]` surface plus a JSON conversion layer, and re-implements none of it. -Distributed as `registry-evidence-client` on PyPI (matching the crate's own -`[lib] name`), imported as `registry_evidence_client`. Publishing to PyPI is -out of scope for this crate; it currently exists to be built and tested -locally and in CI. +Distributed as `registry-evidence-client` on PyPI, imported as +`registry_evidence_client` (matching the crate's own `[lib] name`). Publishing +to PyPI is out of scope for this crate; it currently exists to be built and +tested locally and in CI. ## Python surface @@ -45,10 +45,10 @@ from the package root, with one subclass per stable kind: `ConfigurationError`, `NonceError`, `TokenError`, `TransportError`, `DeniedError`, `NotAvailableError`, `ProtocolError`, `VerificationError`. Every instance carries `kind`; `status`, `code`, `operation`, -`retry_after_seconds`, `transport_kind` (only on a `TransportError`), and -`token_kind` (only on a `TokenError`) are set as attributes only when the -underlying failure carries them. `str(error)` is human prose, not JSON: read -it, do not parse it. +`retry_after_seconds`, `transport_kind` (on a `TransportError`, and on a +`TokenError` whose `token_kind` is `"transport"`), and `token_kind` (only on a +`TokenError`) are set as attributes only when the underlying failure carries +them. `str(error)` is human prose, not JSON: read it, do not parse it. The `denied`/`protocol` split is a hazard worth calling out explicitly: HTTP 401, 403, and 429 all map to `denied` regardless of the response body's own @@ -76,14 +76,15 @@ PyO3's own `PanicException`, before this crate adds anything of its own. See `pyo3-0.29.1/src/impl_/trampoline.rs` (the `trampoline` function) in the vendored source for the exact mechanism. The client's own request nonce is generated through `getrandom::fill`, which reports entropy failure as an -ordinary error and cannot panic. The one latent panic path is upstream and -left unguarded on purpose: the private-key-JWT token provider's `jti` claim, -generated with `Ulid::new()`, reaches `rand::rng()` and panics if OS entropy -is unavailable. It is reachable only in a deployment configured for -private-key-JWT; a static-bearer deployment has no reachable panic at all. -This crate adds no guard of its own for it: the trampoline above already -turns any such panic into an ordinary Python exception rather than a process -abort. +ordinary error and cannot panic. Aside from `to_py_err`'s `set_attr!` calls, +which can only panic under allocation failure, the one latent panic path is +upstream and left unguarded on purpose: the private-key-JWT token provider's +`jti` claim, generated with `Ulid::new()`, reaches `rand::rng()` and panics if +OS entropy is unavailable. It is reachable only in a deployment configured for +private-key-JWT; short of that same allocation-failure-only path, a +static-bearer deployment has no reachable panic at all. This crate adds no +guard of its own for it: the trampoline above already turns any such panic +into an ordinary Python exception rather than a process abort. ### The `unsafe_code` lint @@ -143,7 +144,7 @@ For `cargo test` on macOS, the `auto-initialize` dev-dependency feature (see linker needs it on its search path, for example: ```bash -DYLD_LIBRARY_PATH=/Users/jeremi/.local/share/mise/installs/python/3.13.13/lib cargo test -p registry-evidence-client-py +DYLD_LIBRARY_PATH=/path/to/python/lib cargo test -p registry-evidence-client-py ``` adjusted to the actual interpreter `cargo test` resolves at build time. This diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index 92d855290..559f2f132 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -751,7 +751,7 @@ fn insert_token_fields(mapped: &mut MappedError, error: &TokenError) { mod tests { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use ed25519_dalek::SigningKey; - use evidence_client_sdk::{NonceError, TransportKind}; + use evidence_client_sdk::{NonceError, OAuthErrorCode, TransportKind}; use pyo3::{ types::{PyDict, PyList}, Python, @@ -1197,13 +1197,54 @@ mod tests { assert_eq!(mapped.transport_kind, Some("response_too_large")); } + #[test] + fn map_client_error_carries_the_verifier_kind_as_the_code() { + let mapped = map_client_error(&EvidenceClientError::Verification( + VerificationError::Signature, + )); + assert_eq!(mapped.kind, "verification"); + assert_eq!(mapped.code.as_deref(), Some("signature")); + } + + /// Every one of `TokenError`'s six sub-kinds carries its own `token_kind` + /// under the client-level "token" kind, and only its own further sub-field + /// (`transport_kind`, `code`, or `status`) alongside it. #[test] fn map_client_error_carries_the_token_kind_and_its_own_sub_fields() { - let mapped = map_client_error(&EvidenceClientError::Token(TokenError::Transport { + let unavailable = map_client_error(&EvidenceClientError::Token(TokenError::Unavailable)); + assert_eq!(unavailable.kind, "token"); + assert_eq!(unavailable.token_kind, Some("unavailable")); + + let invalid = map_client_error(&EvidenceClientError::Token(TokenError::Invalid { + reason: "a bearer credential must be non-empty and within the accepted length", + })); + assert_eq!(invalid.kind, "token"); + assert_eq!(invalid.token_kind, Some("invalid_credential")); + + let configuration = + map_client_error(&EvidenceClientError::Token(TokenError::Configuration { + reason: "the token provider cannot be used this way", + })); + assert_eq!(configuration.kind, "token"); + assert_eq!(configuration.token_kind, Some("configuration")); + + let transport = map_client_error(&EvidenceClientError::Token(TokenError::Transport { kind: TransportKind::Timeout, })); - assert_eq!(mapped.token_kind, Some("transport")); - assert_eq!(mapped.transport_kind, Some("timeout")); + assert_eq!(transport.token_kind, Some("transport")); + assert_eq!(transport.transport_kind, Some("timeout")); + + let refused = map_client_error(&EvidenceClientError::Token(TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + })); + assert_eq!(refused.token_kind, Some("refused")); + assert_eq!(refused.code.as_deref(), Some("invalid_client")); + + let protocol = map_client_error(&EvidenceClientError::Token(TokenError::Protocol { + status: 500, + })); + assert_eq!(protocol.token_kind, Some("protocol")); + assert_eq!(protocol.status, Some(500)); } /// `code` and `operation` are already bounded upstream diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs index d5058370a..ecdc15435 100644 --- a/crates/registry-evidence-client-py/src/lib.rs +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -36,8 +36,9 @@ use convert::{ // this crate does not freeze. // // Where the failure has them, an instance also carries `status`, `code`, -// `operation`, `retry_after_seconds`, `transport_kind` (set only for a -// "transport" failure), and `token_kind` (set only for a "token" failure). +// `operation`, `retry_after_seconds`, `transport_kind` (set for a "transport" +// failure, and for a "token" failure whose `token_kind` is "transport"), and +// `token_kind` (set only for a "token" failure). // A "protocol" failure with `status` 401, 403, or 429 is reachable: it means // the deployment answered outside its own contract (an uncoded refusal, or a // response this client could not parse) rather than with a contract-coded @@ -515,18 +516,19 @@ mod tests { /// `pyo3-0.29.1/src/panic.rs` (`PanicException` itself, at /// `pyo3::panic::PanicException`, not re-exported at the crate root). /// - /// This crate has exactly one latent panic to worry about, and it is not - /// the client's own request nonce: that one is generated through - /// `getrandom::fill`, which reports entropy failure as an ordinary error - /// rather than panicking. The unguarded path is the private-key-JWT token - /// provider's own `jti` claim, generated with `Ulid::new()`, which reaches - /// `rand::rng()` and panics when OS entropy is unavailable, a case that - /// provider deliberately leaves unguarded. It is reachable only in a - /// deployment configured for private-key-JWT: a static-bearer deployment - /// never calls that provider, so it has no reachable panic at all. No - /// extra guard is added here for it: this test proves the boundary - /// already turns any such panic into an ordinary Python exception instead - /// of a process abort. + /// Aside from `to_py_err`'s `set_attr!` calls, which can only panic under + /// allocation failure, this crate has exactly one latent panic to worry + /// about, and it is not the client's own request nonce: that one is + /// generated through `getrandom::fill`, which reports entropy failure as + /// an ordinary error rather than panicking. The unguarded path is the + /// private-key-JWT token provider's own `jti` claim, generated with + /// `Ulid::new()`, which reaches `rand::rng()` and panics when OS entropy + /// is unavailable, a case that provider deliberately leaves unguarded. It + /// is reachable only in a deployment configured for private-key-JWT: a + /// static-bearer deployment never calls that provider, so short of that + /// same allocation-failure-only path it has no reachable panic at all. No extra guard is added here for it: this test + /// proves the boundary already turns any such panic into an ordinary + /// Python exception instead of a process abort. /// /// The catch must be exercised from Python code, not from a Rust `call0`: /// `PyErr::take` (which every pyo3 call-from-Rust helper uses to read the @@ -571,4 +573,36 @@ except BaseException as error: assert_eq!(caught, "PanicException"); }); } + + /// `exception_for_kind`'s catch-all arm (`_ => + /// EvidenceClientError::new_err(message)`) is deliberate: it keeps this + /// binding compiling and useful as the wrapped `#[non_exhaustive]` error + /// enum grows a kind this crate does not yet know about. The same + /// catch-all also means that renaming one of the known kind strings in an + /// existing match arm degrades that kind to the base class silently + /// instead of failing to compile, so this pins every one of the eight + /// known kind strings to its own specific exception class directly. + #[test] + fn exception_for_kind_maps_every_known_kind_to_its_specific_class() { + Python::attach(|py| { + assert!(exception_for_kind("configuration", "message".to_owned()) + .is_instance_of::(py)); + assert!( + exception_for_kind("nonce", "message".to_owned()).is_instance_of::(py) + ); + assert!( + exception_for_kind("token", "message".to_owned()).is_instance_of::(py) + ); + assert!(exception_for_kind("transport", "message".to_owned()) + .is_instance_of::(py)); + assert!(exception_for_kind("denied", "message".to_owned()) + .is_instance_of::(py)); + assert!(exception_for_kind("not_available", "message".to_owned()) + .is_instance_of::(py)); + assert!(exception_for_kind("protocol", "message".to_owned()) + .is_instance_of::(py)); + assert!(exception_for_kind("verification", "message".to_owned()) + .is_instance_of::(py)); + }); + } } From f22d1078a66e121a0efd0d2b52ce3c873f2491ab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:46:34 +0700 Subject: [PATCH 41/67] docs: state the client boundary and the binding checks in the guidance The Evidence product boundary governed the runtime, the verifier, and `evidencectl`, while the repository map directly above it already introduced the SDK and both bindings, so the document a contributor reads first stated no rule about whether the SDK may add Evidence semantics or where the bindings sit relative to the frozen Version 1 contract. "Verify your change" likewise omitted the two binding suites that gate their PR. The verifier sentence claimed no platform-specific dependency, which reads as the target independence the crate's own module doc explicitly disclaims. Signed-off-by: Jeremi Joslin --- AGENTS.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6a8e83ab5..8c01e5dcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,17 @@ runtime depends on. It owns the response wire formats, the Evidence payload contract, and relying-party verification, so client tooling can verify a signed Evidence response without the runtime. It is a library, not a second runtime and not a pattern of its own, and it carries no server, source access, or -platform-specific dependency. +service-runtime dependency; portable means free of the service runtime, not +target independent. + +`registry-evidence-client` is the relying-party SDK beside the runtime. It +requests assertions over the public HTTP contract and links +`registry-evidence-verifier` for every verification decision, so it sits outside +the frozen Version 1 runtime contract and adds no Evidence semantics of its own. +`registry-evidence-client-node` (napi-rs) and `registry-evidence-client-py` +(PyO3) are thin bindings over that SDK and carry the same boundary. All three +are covered by the same source-product and domain neutrality checks as the +runtime. `registry-evidencectl` (`evidencectl`) is adopter tooling beside the runtime, like `registryctl` is for the rest of the stack. It sits outside the frozen @@ -145,6 +155,25 @@ products/evidence/scripts/check-source-neutrality.sh products/evidence/scripts/check-verifier-portability.sh ``` +Evidence client bindings, from `crates/registry-evidence-client-node`: + +```bash +npm ci +npm run build:debug +npm test +npm run check:types +cmp ../../LICENSE LICENSE +``` + +and from `crates/registry-evidence-client-py`: + +```bash +cargo build --locked -p registry-evidence-client-py --lib \ + --features registry-evidence-client-py/extension-module +python3 -m unittest discover -s tests/python -v +cmp ../../LICENSE LICENSE +``` + Release source checks: ```bash From 166323044a99765879c821aaa5d090c1ea44c867 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:46:34 +0700 Subject: [PATCH 42/67] docs(evidence): name the syscall layers the portability gate denies The README listed five dependency categories the check proves absent, but the script also denies `fs2`, `rustix`, and `socket2`, so a tree could satisfy all five named categories and still fail the check. Signed-off-by: Jeremi Joslin --- crates/registry-evidence-verifier/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence-verifier/README.md b/crates/registry-evidence-verifier/README.md index 08598088f..12812f8d8 100644 --- a/crates/registry-evidence-verifier/README.md +++ b/crates/registry-evidence-verifier/README.md @@ -65,8 +65,9 @@ products/evidence/scripts/check-verifier-portability.sh ``` The second command proves the normal dependency tree still carries no async -runtime, HTTP stack, script engine, command line parser, or logging framework, -so client tooling links none of the service runtime. +runtime, HTTP stack, script engine, command line parser, logging framework, or +filesystem and socket syscall layer, so client tooling links none of the service +runtime. It does not make the crate target independent. The crypto stack reaches `aws-lc-sys`, so a build needs a C toolchain and is limited to the targets From 10a84bc9679d7b9752a9d9896e3d540f7c018fd8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:46:34 +0700 Subject: [PATCH 43/67] docs(evidence): cover the client crates in the Evidence gates and inventories The crate tree and Definition of Done gate block in `IMPLEMENTATION.md`, and the reproducible gate in the product README, all predate the four crates this branch adds: following either verbatim skips the verifier's tests, the client's tests, and the portability check. The README also never told an adopter what to use to consume Evidence from an application. The anti-proliferation clause regains the word `client`, because what it forbids is carving a runtime responsibility out into a separate crate rather than shipping a relying-party SDK that adds no Evidence semantics, and `registry-evidence-verifier` is named as the one approved decomposition and declared closed. The neutrality gate now sweeps the bindings' shipped non-Rust surface, which it could not see while it scanned only Rust under `src/`. Signed-off-by: Jeremi Joslin --- products/evidence/IMPLEMENTATION.md | 19 +++++--- products/evidence/README.md | 46 +++++++++++++++++-- .../scripts/check-source-neutrality.sh | 27 +++++++++-- .../scripts/check-verifier-portability.sh | 9 ++-- 4 files changed, 85 insertions(+), 16 deletions(-) diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 4d2e5c1fe..ea12db357 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -69,6 +69,8 @@ crates/registry-evidence-verifier/ verifier.rs crates/registry-evidencectl/ crates/registry-evidence-client/ +crates/registry-evidence-client-node/ +crates/registry-evidence-client-py/ products/evidence/ contracts/ fixtures/ @@ -86,12 +88,16 @@ evidence evaluate --fixture evidence verify --jws --jwks --policy ``` -Do not decompose the runtime into worker, adapter, policy, credential, or -interoperability crates in version one. Rhai adapters and derivations are -deployment-bundle artifacts, not Rust crates. The adopter tooling and the -relying-party client library named above sit outside the frozen Version 1 -runtime contract, delegate every Evidence semantic decision to the runtime or to -the portable verifier, and add no Evidence semantics of their own. +Do not create client, worker, adapter, policy, credential, or interoperability +crates in version one. What the prohibition forbids is carving a runtime +responsibility out into a separate crate, not shipping a relying-party SDK that +adds no Evidence semantics of its own. `registry-evidence-verifier` is the one +approved decomposition of the runtime, and it is closed: no further extraction +is approved. Rhai adapters and derivations are deployment-bundle artifacts, not +Rust crates. The adopter tooling, the relying-party client, and its Node and +Python bindings named above sit outside the frozen Version 1 runtime contract, +delegate every Evidence semantic decision to the runtime or to the portable +verifier, and add no Evidence semantics of their own. Production code is source-product neutral. `src/`, production Cargo features, dependencies, public types, configuration schemas, routes, and CLI options @@ -919,6 +925,7 @@ cargo test --locked --workspace cargo deny check products/evidence/scripts/check-contracts.sh products/evidence/scripts/check-source-neutrality.sh +products/evidence/scripts/check-verifier-portability.sh ``` Generated artifacts are reproduced from code, never hand-edited. If a shared diff --git a/products/evidence/README.md b/products/evidence/README.md index c54760cbe..ad843c413 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -162,6 +162,26 @@ storage, and keeps Evidence private behind operator TLS. Container, Helm, Kubernetes, Terraform, cloud packaging, approval, promotion, and deployment commands remain outside this build command. +### Relying-party client library + +`registry-evidence-client` is the Rust SDK for the application side of the +contract. It generates the request nonce and closes the verification policy +before any byte leaves the process, sends the request over the public HTTP +contract, and hands every judgement about the response to +`registry-evidence-verifier`, so it adds no Evidence semantics of its own. +`registry-evidence-client-node` binds that crate for Node.js callers through +napi-rs and `registry-evidence-client-py` binds it for Python callers through +PyO3; each is a thin surface plus a JSON conversion layer over the same Rust +decisions. Neither binding is published: the npm package is private and PyPI +publishing is out of scope for the crate, so both are built and tested from +source here. + +Each crate documents its own surface and test commands: +[`registry-evidence-client`](../../crates/registry-evidence-client/README.md), +[`registry-evidence-client-node`](../../crates/registry-evidence-client-node/README.md), +and +[`registry-evidence-client-py`](../../crates/registry-evidence-client-py/README.md). + ## Installing the toolset Releases that include the Evidence toolset publish reproducible bare binaries @@ -301,13 +321,33 @@ From the monorepo root, the Evidence-specific reproducible gate is: ```sh cargo fmt --check -cargo check --locked -p registry-evidence -p registry-evidencectl --all-targets -cargo test --locked -p registry-evidence -p registry-evidencectl -cargo clippy --locked -p registry-evidence -p registry-evidencectl --all-targets -- -D warnings +cargo check --locked \ + -p registry-evidence -p registry-evidence-verifier \ + -p registry-evidence-client -p registry-evidence-client-node \ + -p registry-evidence-client-py -p registry-evidencectl \ + --all-targets +cargo test --locked \ + -p registry-evidence -p registry-evidence-verifier \ + -p registry-evidence-client -p registry-evidence-client-node \ + -p registry-evidence-client-py -p registry-evidencectl +cargo clippy --locked \ + -p registry-evidence -p registry-evidence-verifier \ + -p registry-evidence-client -p registry-evidence-client-node \ + -p registry-evidence-client-py -p registry-evidencectl \ + --all-targets -- -D warnings products/evidence/scripts/check-contracts.sh products/evidence/scripts/check-source-neutrality.sh +products/evidence/scripts/check-verifier-portability.sh ``` +The two bindings also carry their own JavaScript and Python suites, which this +gate does not run; each binding's README states the commands, and root CI runs +them in its client-bindings job. On macOS, `cargo test` for +`registry-evidence-client-py` needs the interpreter's library directory on the +dynamic linker path, as +[the Python binding README](../../crates/registry-evidence-client-py/README.md) +describes. + Generated JSON Schema and OpenAPI artifacts are under `generated/`. The contract gate recreates them from Rust in a temporary directory and requires an exact diff. The complete workspace and dependency-policy gates remain those in diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index 4b6acea4b..6058b648c 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -134,8 +134,20 @@ sys.stdout.write(source[cursor:]) PY done +# The two bindings ship a non-Rust surface that the Rust sweep above cannot see. +# Enumerate exactly those shipped files: a sweep of the binding crate directories +# would also reach their tests, fixtures, and installed dependencies, where a +# source-product name is allowed. if rg -n -i 'dhis2|opencrvs' \ "$production_text" \ + "$repository_root/crates/registry-evidence-client-node/client.js" \ + "$repository_root/crates/registry-evidence-client-node/client.d.ts" \ + "$repository_root/crates/registry-evidence-client-node/index.js" \ + "$repository_root/crates/registry-evidence-client-node/index.d.ts" \ + "$repository_root/crates/registry-evidence-client-node/package.json" \ + "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py" \ + "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi" \ + "$repository_root/crates/registry-evidence-client-py/pyproject.toml" \ "$repository_root/crates/registry-evidence/Cargo.toml" \ "$repository_root/crates/registry-evidence-client/Cargo.toml" \ "$repository_root/crates/registry-evidence-client-node/Cargo.toml" \ @@ -143,13 +155,22 @@ if rg -n -i 'dhis2|opencrvs' \ "$repository_root/crates/registry-evidence-verifier/Cargo.toml" \ "$repository_root/crates/registry-evidencectl/Cargo.toml" \ "$repository_root/Cargo.toml"; then - echo 'Evidence production code, adopter tooling, or Cargo metadata contains a prohibited source-product name.' >&2 + echo 'Evidence production code, adopter tooling, the shipped binding surface, or Cargo metadata contains a prohibited source-product name.' >&2 exit 1 fi +# The two package manifests stay out of this sweep: their SPDX license field +# matches the licence pattern, and neither declares a caller-visible API surface +# that could name an acceptance case. if rg -n -i 'adult|age[_ -]?at|residence|licen[cs]e|parentage|legal[_ -]?parent|given_name|family_name|birth_date|national[_ -]?identifier' \ - "$production_text"; then - echo 'Evidence production Rust or adopter tooling contains acceptance-case or jurisdiction-specific vocabulary.' >&2 + "$production_text" \ + "$repository_root/crates/registry-evidence-client-node/client.js" \ + "$repository_root/crates/registry-evidence-client-node/client.d.ts" \ + "$repository_root/crates/registry-evidence-client-node/index.js" \ + "$repository_root/crates/registry-evidence-client-node/index.d.ts" \ + "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py" \ + "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi"; then + echo 'Evidence production Rust, adopter tooling, or the shipped binding surface contains acceptance-case or jurisdiction-specific vocabulary.' >&2 exit 1 fi diff --git a/products/evidence/scripts/check-verifier-portability.sh b/products/evidence/scripts/check-verifier-portability.sh index 79fa0ecdd..e5ae3584e 100755 --- a/products/evidence/scripts/check-verifier-portability.sh +++ b/products/evidence/scripts/check-verifier-portability.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -# Client tooling links the portable Evidence verifier to check a stored -# response, on any platform and without the runtime. Keep its normal dependency -# tree free of the async runtime, HTTP stack, script engine, command line -# parser, and logging framework that the runtime carries. +# Client tooling links the portable Evidence verifier to check a stored response +# without the runtime. Portable means free of the service runtime, not target +# independent. Keep its normal dependency tree free of the async runtime, HTTP +# stack, script engine, command line parser, logging framework, and filesystem +# and socket syscall layers that the runtime carries. CDPATH='' repository_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd) From 1c1fd0ee5e4a7025d1ea5407bc949749299ffeb6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:46:34 +0700 Subject: [PATCH 44/67] docs(site): complete the evidence-contracts job enumeration Two published pages describe that CI job by listing its steps, and it also runs the verifier portability script. Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/reference/api-stability.mdx | 2 +- docs/site/src/content/docs/spec/rs-pr-evidence.mdx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/site/src/content/docs/reference/api-stability.mdx b/docs/site/src/content/docs/reference/api-stability.mdx index e4b17a1ff..8353fbbb1 100644 --- a/docs/site/src/content/docs/reference/api-stability.mdx +++ b/docs/site/src/content/docs/reference/api-stability.mdx @@ -45,7 +45,7 @@ The enforcement column names the repository CI checks so the mechanism is audita | Surface | Contract artifact | Enforcement today | | --- | --- | --- | | Registry Relay HTTP API (approved stable public and admin listener surface) | Committed OpenAPI document `crates/registry-relay/openapi/registry-relay.openapi.json`, scoped by the [authoritative 1.0 support roster](../apis/registry-relay/) | `just openapi-contract` checks byte-for-byte generation, filters only roster entries declared `included_unstable`, then runs `oasdiff breaking` against the base ref | -| Evidence Gateway HTTP API and its Version 1 contracts | The frozen Version 1 source contracts under `products/evidence/contracts/`, indexed by `products/evidence/contracts/README.md`, and the artifacts generated from them under `products/evidence/generated/`, including `registry-evidence.openapi.json` | Root CI's `evidence-contracts` job runs `products/evidence/scripts/check-contracts.sh`, which regenerates every contract artifact and fails on any byte difference from the committed copies, and `products/evidence/scripts/check-source-neutrality.sh` | +| Evidence Gateway HTTP API and its Version 1 contracts | The frozen Version 1 source contracts under `products/evidence/contracts/`, indexed by `products/evidence/contracts/README.md`, and the artifacts generated from them under `products/evidence/generated/`, including `registry-evidence.openapi.json` | Root CI's `evidence-contracts` job runs `products/evidence/scripts/check-contracts.sh`, which regenerates every contract artifact and fails on any byte difference from the committed copies, plus `products/evidence/scripts/check-source-neutrality.sh` and `products/evidence/scripts/check-verifier-portability.sh` | | Error contract and stable identifiers | RFC 9457 problem shape with the stable `code` member, the [error registry](../errors/), the `pdp.*` denial codes, and the `https://id.registrystack.org/` identifier space. Evidence Gateway's closed problem set has its own frozen contract, `products/evidence/contracts/problem-contract.yaml`, documented at [Evidence Gateway problem types](../evidence-problems/) | `release/scripts/check-stable-surface-compatibility.py` permits additions and rejects removal or changed meaning; product tests pin runtime rendering and status mappings; the `evidence-contracts` job byte-diffs the generated Evidence Gateway problem schema | | Configuration formats and documented environment variables | The committed Draft 2020-12 schema `schemas/registry-relay.config.schema.json`, the frozen Evidence Gateway schemas `products/evidence/contracts/runtime.schema.yaml` and `products/evidence/contracts/bundle.schema.yaml`, plus the [environment variable reference](../environment-variables/) | Relay's `config-schema-check` command reproduces its schema from the typed config graph; Evidence Gateway's config parser is tested against the frozen contract schemas (`crates/registry-evidence/src/config.rs`); strict parsers and the deprecated-field guard reject unknown or retired fields | | Signed config bundle format and verification semantics | `registry.platform.config_bundle.v1` manifests, `registry.platform.config_bundle_signatures.v1` signature envelopes, and `registry.platform.config_trust_anchor.v1` trust anchors. Normal signed verification requires a valid, non-empty trust anchor and checks signature acceptance, file closure, product/environment/stream binding, optional instance pinning, and anti-rollback sequence. The hash-pinned `registry.platform.config_break_glass.v1` `accept_rollback` mode keeps signature, binding, closure, hash, and product checks but waives the monotonic sequence rejection for a signed bundle whose config hash matches the override. Its `accept_unsigned` mode skips signature, binding, and sequence checks while retaining exact hash pinning and product config validation. | Shared verifier tests in `registry-platform-config`; break-glass consumption, restart-pin, and sequence tests in `registry-platform-ops`; product CLI coverage in `crates/registry-relay/tests/config_verify_bundle_cli.rs` | diff --git a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx index f5858ae9c..46280ea0a 100644 --- a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx @@ -655,7 +655,8 @@ that evidence gap remains marked with an author comment in the source of this pa `products/evidence/scripts/check-contracts.sh`, which regenerates the artifacts under `products/evidence/generated/` from the `registry-evidence` crate into a temporary directory and fails on any byte difference from the committed set. The same job runs - `products/evidence/scripts/check-source-neutrality.sh`. + `products/evidence/scripts/check-source-neutrality.sh` and + `products/evidence/scripts/check-verifier-portability.sh`. - Narrative contracts and requirements that are not generated remain review-owned. Their cited tests and traceability entries are inspectable evidence, not a claim that CI mechanically proves every sentence in those documents. From 07ba8c6fb5eb28f8c453b471b4e8b4c71067b9bd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 5 Aug 2026 23:46:34 +0700 Subject: [PATCH 45/67] fix(relay): correct and test the SP DCI response schema contract The configuration guide said an external `$ref` fails config validation with `spdci.config.schema_compile_failed`. It does not: `$ref` resolution in the schema compiler is lazy, so such a schema loads and then fails every record at request time, which answers `500 internal.unhandled` for any non-empty result. Nothing in the tree tested that behavior, nor the internal `#/` resolution the same paragraph promises. The guide now states what the compiler does: which draft a schema compiles under, the silent draft 7 fallback for a draft it does not carry, and that 2020-12 treats `format` as an annotation rather than an assertion, which can widen what an adopter's committed schema accepts. Eight characterization tests pin all of it through `config::load` and the response mapper, including that no request reaches a mock upstream serving the referenced document. Each was proven load-bearing against a scratch crate toggling the schema compiler's features. Signed-off-by: Jeremi Joslin --- crates/registry-relay/CHANGELOG.md | 20 ++ crates/registry-relay/docs/configuration.md | 4 +- .../tests/spdci_config_validation.rs | 187 ++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index de11ec801..dca3a3e01 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- SP DCI `response_schema_path` schemas compile under a schema compiler that + carries JSON Schema drafts 4, 6, 7, and 2020-12 and resolves no remote or file + references. A schema declaring 2020-12 in `$schema` is compiled as 2020-12, + which treats `format` as an annotation instead of an assertion, so a committed + schema that leans on `format` to constrain a value needs an explicit `pattern` + or `enum` to keep constraining it. A schema with no `$schema`, or one naming a + draft the compiler does not carry such as 2019-09, compiles under draft 7, + which does assert `format`. The 2020-12 keywords `prefixItems`, + `dependentRequired`, and `dependentSchemas` are enforced whatever draft a + schema declares. An external `$ref` naming an `http(s)://` or `file://` target + is never fetched or read, at config validation or while serving a response, so + inline the referenced definitions. The SP DCI sync adapter is built only with + `--features spdci-api-standards`; released Relay images do not carry it. +- Correct the `response_schema_path` self-containment contract in the + configuration guide. Config validation accepts a schema carrying an external + `$ref` rather than refusing it with `spdci.config.schema_compile_failed`, and + the unresolved reference instead fails every record at request time, so SP DCI + generic search, details, and support answer `500 internal.unhandled` for any + non-empty result. + ## 0.16.3 - 2026-08-01 - No user-visible Registry Relay changes. The v0.16.2 workflow stopped at an diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index ef21d328e..a5ef19c72 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -1072,7 +1072,9 @@ For generic sync search, `identifiers` maps DCI `idtype-value` query types to en For `/dci/{registry}/registry/sync/disabled`, the caller needs the entity `evidence_verification_scope`. Generic search, details, and support need the entity `read_scope`. API-key authentication is still Registry Relay's normal auth layer. If a registry entry uses `response_mapping_path`, the binary must also be built with `--features standards-cel-mapping`; otherwise config validation fails with `spdci.config.mapping_feature_disabled`. -A `response_schema_path` schema must be self-contained. Internal `#/` references resolve normally, but an external `$ref` naming an `http(s)://` or `file://` target fails config validation with `spdci.config.schema_compile_failed`, because the schema compiler resolves no remote or file references and validating configuration never makes a network request. Inline the referenced definitions instead. +A `response_schema_path` schema must be self-contained. Internal `#/` references resolve normally. An external `$ref` naming an `http(s)://` or `file://` target is never resolved, because the schema compiler carries no remote or file resolver: no request is made and no referenced file is read, at config validation or while serving a response. Such a schema still passes config validation, and the unresolved reference then fails every record, so generic search, details, and support answer `500 internal.unhandled` for any non-empty result. Inline the referenced definitions instead. `spdci.config.schema_compile_failed` reports a schema the compiler rejects outright, such as an unknown `type` name, a keyword holding the wrong JSON type, or a `pattern` that is not a valid regular expression. + +The compiler carries JSON Schema drafts 4, 6, 7, and 2020-12, and compiles each schema under the draft its `$schema` names. A schema with no `$schema`, or one naming a draft the compiler does not carry such as 2019-09, compiles under draft 7 with no diagnostic. The declared draft decides whether `format` is asserted: drafts 4, 6, and 7 reject a value that does not match its declared `format`, while 2020-12 treats `format` as an annotation and accepts the value, so a schema declaring 2020-12 needs an explicit `pattern` or `enum` wherever it relies on `format` to constrain a value. The 2020-12 keywords `prefixItems`, `dependentRequired`, and `dependentSchemas` are enforced whatever draft a schema declares. ## API keys diff --git a/crates/registry-relay/tests/spdci_config_validation.rs b/crates/registry-relay/tests/spdci_config_validation.rs index b6b0ca028..50932958d 100644 --- a/crates/registry-relay/tests/spdci_config_validation.rs +++ b/crates/registry-relay/tests/spdci_config_validation.rs @@ -5,7 +5,10 @@ use std::path::{Path, PathBuf}; +use registry_platform_testing::MockHttpUpstream; use registry_relay::config; +use registry_relay::spdci::{build_spdci_response_mapper, SpdciResponseMappingError}; +use serde_json::{json, Value}; use tempfile::TempDir; fn yaml_path(path: &Path) -> String { @@ -119,6 +122,46 @@ fn assert_config_code(path: &Path, expected_code: &str) { assert_eq!(err.code(), expected_code); } +/// Writes `schema` as the single registry's `response_schema_path` document and +/// returns the config path. +fn write_schema_config(tmp: &TempDir, schema: &Value) -> PathBuf { + let schema_path = tmp.path().join("response.schema.json"); + std::fs::write( + &schema_path, + serde_json::to_string(schema).expect("schema serializes"), + ) + .expect("write schema"); + write_config( + tmp, + &format!( + r#" response_schema_path: {} +"#, + yaml_path(&schema_path) + ), + ) +} + +/// Loads a config carrying `schema` and runs `record` through the response +/// mapper, which is the path that applies a compiled response schema. +fn project_under_schema( + tmp: &TempDir, + schema: &Value, + record: Value, +) -> Result { + let config_path = write_schema_config(tmp, schema); + let cfg = config::load(&config_path).expect("config loads"); + let mapper = build_spdci_response_mapper(&cfg) + .expect("response mapper builds") + .expect("response mapper is installed"); + let registry = &cfg + .standards + .spdci + .as_ref() + .expect("spdci config") + .registries["dr"]; + mapper.project_record("dr", registry, record) +} + #[test] fn spdci_response_fields_and_schema_config_load() { let tmp = TempDir::new().expect("tempdir"); @@ -220,3 +263,147 @@ fn spdci_response_mapping_path_rejects_missing_file() { assert_config_code(&config_path, "config.validation_error"); } + +#[test] +fn spdci_response_schema_path_rejects_an_uncompilable_schema() { + let tmp = TempDir::new().expect("tempdir"); + let config_path = write_schema_config(&tmp, &json!({"type": "objekt"})); + + assert_config_code(&config_path, "config.validation_error"); +} + +#[tokio::test] +async fn spdci_response_schema_never_requests_a_remote_reference() { + let upstream = MockHttpUpstream::start().await; + // The served document accepts the record below, so a resolved reference + // would let the record through. + upstream + .expect("GET", "/person.schema.json") + .respond_json(200, json!({"type": "object", "required": ["id"]})) + .await; + let schema = json!({ + "type": "object", + "properties": { + "person": {"$ref": format!("{}/person.schema.json", upstream.url())} + } + }); + + let tmp = TempDir::new().expect("tempdir"); + let result = project_under_schema(&tmp, &schema, json!({"person": {"id": "p-1"}})); + + assert!( + matches!( + result, + Err(SpdciResponseMappingError::SchemaValidationFailed) + ), + "an unresolvable remote reference must refuse every record, got {result:?}" + ); + assert!( + upstream + .wiremock_server() + .received_requests() + .await + .expect("upstream records requests") + .is_empty(), + "neither config validation nor response validation may request a remote schema" + ); +} + +#[test] +fn spdci_response_schema_never_reads_a_file_reference() { + let tmp = TempDir::new().expect("tempdir"); + let referenced = tmp.path().join("person.schema.json"); + // The referenced document accepts the record below, so a resolved reference + // would let the record through. + std::fs::write(&referenced, r#"{"type":"object","required":["id"]}"#) + .expect("write referenced schema"); + let schema = json!({ + "type": "object", + "properties": { + "person": {"$ref": format!("file://{}", referenced.display())} + } + }); + + let result = project_under_schema(&tmp, &schema, json!({"person": {"id": "p-1"}})); + + assert!( + matches!( + result, + Err(SpdciResponseMappingError::SchemaValidationFailed) + ), + "an unresolvable file reference must refuse every record, got {result:?}" + ); +} + +#[test] +fn spdci_response_schema_resolves_internal_pointer_references() { + let schema = json!({ + "type": "object", + "properties": {"person": {"$ref": "#/definitions/person"}}, + "definitions": {"person": {"type": "object", "required": ["id"]}} + }); + + let tmp = TempDir::new().expect("tempdir"); + project_under_schema(&tmp, &schema, json!({"person": {"id": "p-1"}})) + .expect("a record satisfying the referenced definition passes"); + project_under_schema(&tmp, &schema, json!({"person": {}})) + .expect_err("the referenced definition is enforced"); +} + +#[test] +fn spdci_response_schema_enforces_draft_2020_12_keywords() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"codes": {"type": "array", "prefixItems": [{"type": "string"}]}} + }); + + let tmp = TempDir::new().expect("tempdir"); + project_under_schema(&tmp, &schema, json!({"codes": ["a"]})) + .expect("a record matching the leading prefixItems entry passes"); + project_under_schema(&tmp, &schema, json!({"codes": [42]})) + .expect_err("prefixItems is enforced rather than ignored as an unknown keyword"); +} + +#[test] +fn spdci_response_schema_does_not_assert_format_under_draft_2020_12() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"born_on": {"type": "string", "format": "date"}} + }); + + let tmp = TempDir::new().expect("tempdir"); + project_under_schema(&tmp, &schema, json!({"born_on": "not-a-date"})) + .expect("format is an annotation under 2020-12"); +} + +#[test] +fn spdci_response_schema_asserts_format_under_draft_7() { + let schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"born_on": {"type": "string", "format": "date"}} + }); + + let tmp = TempDir::new().expect("tempdir"); + project_under_schema(&tmp, &schema, json!({"born_on": "2026-08-05"})) + .expect("a record matching the declared format passes"); + project_under_schema(&tmp, &schema, json!({"born_on": "not-a-date"})) + .expect_err("format is an assertion under draft 7"); +} + +#[test] +fn spdci_response_schema_falls_back_to_draft_7_for_an_uncarried_draft() { + // 2019-09 is not carried, so this schema compiles under draft 7 and its + // `format` becomes an assertion. + let schema = json!({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": {"born_on": {"type": "string", "format": "date"}} + }); + + let tmp = TempDir::new().expect("tempdir"); + project_under_schema(&tmp, &schema, json!({"born_on": "not-a-date"})) + .expect_err("an uncarried draft falls back to draft 7, which asserts format"); +} From f400f54b648ef4db79039c8df9e555125e6f4d4f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 11:30:30 +0700 Subject: [PATCH 46/67] feat(evidence): publish relying-party clients with development builds Until a tagged release exists there is no way to try the Python or Node client without building it, so the manual development build now produces one wheel and one npm tarball per platform beside the three binaries, smokes each from a fresh environment, and publishes them with the prerelease. Each package carries its ecosystem's own prerelease version, so a tester can tell from `pip show` or `npm ls` which build is installed. `pyo3`'s `abi3-py310` feature makes one wheel per platform cover CPython 3.10 and later, so there is no Python-version matrix. Linux wheels are built with `--compatibility linux` rather than maturin's manylinux audit, whose tag is not predictable from the source; the resulting glibc floor is stated in the release notes. The Rust client is not published: a git dependency pinned to the source commit already works without a release. Review notes (release provenance): the closed asset roster is extended by the six new names, so a missing or misnamed client artifact fails publication instead of shipping a partial toolset; SHA256SUMS covers the new assets by construction; the new job holds `contents: read` only and needs no registry credentials, since nothing is published to PyPI or npm. Signed-off-by: Jeremi Joslin --- .github/workflows/evidence-dev.yml | 334 ++++++++++++++++++- crates/registry-evidence-client-py/README.md | 8 +- 2 files changed, 339 insertions(+), 3 deletions(-) diff --git a/.github/workflows/evidence-dev.yml b/.github/workflows/evidence-dev.yml index c9c0c18ba..ea9f586cd 100644 --- a/.github/workflows/evidence-dev.yml +++ b/.github/workflows/evidence-dev.yml @@ -26,6 +26,8 @@ jobs: source_sha: ${{ steps.identity.outputs.source_sha }} version: ${{ steps.identity.outputs.version }} tag: ${{ steps.identity.outputs.tag }} + python_version: ${{ steps.identity.outputs.python_version }} + node_version: ${{ steps.identity.outputs.node_version }} steps: - name: Checkout exact workflow source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.2.2 @@ -110,6 +112,17 @@ jobs: echo "source_sha=${GITHUB_SHA}" echo "version=${version}" echo "tag=${tag}" + # The client packages carry a prerelease version of their own, so + # a tester who installs one can tell from `pip show` or + # `npm ls` which build they are running rather than reading a file + # name. Each ecosystem's own prerelease grammar: a PEP 440 + # development release, which sorts below the eventual real + # ${version}, and the semantic-version equivalent. Neither carries + # the run attempt, whose only legal PEP 440 home is a local + # version segment that some installers reject; the asset file + # names below carry the full tag, attempt included. + echo "python_version=${version}.dev${GITHUB_RUN_ID}" + echo "node_version=${version}-dev.${GITHUB_RUN_ID}" } >> "${GITHUB_OUTPUT}" build: @@ -181,11 +194,295 @@ jobs: if-no-files-found: error retention-days: 2 + clients: + name: Build Evidence dev clients for ${{ matrix.asset }} + needs: validate + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + asset: linux-amd64 + # `pyo3`'s `abi3-py310` feature (see the workspace `Cargo.toml`) + # makes one wheel per platform cover every CPython from 3.10 up, + # so these tags are the whole roster: no Python-version matrix. + wheel_tag: cp310-abi3-linux_x86_64 + napi_platform: linux-x64-gnu + - runner: ubuntu-24.04-arm + asset: linux-arm64 + wheel_tag: cp310-abi3-linux_aarch64 + napi_platform: linux-arm64-gnu + - runner: macos-14 + asset: macos-arm64 + wheel_tag: cp310-abi3-macosx_11_0_arm64 + napi_platform: darwin-arm64 + steps: + - name: Checkout exact development source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.source_sha }} + fetch-depth: 1 + persist-credentials: false + submodules: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4.4.0 + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: crates/registry-evidence-client-node/package-lock.json + + - name: Restore Evidence development client Cargo cache + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v4.2.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: registry-evidence-dev-clients-${{ matrix.asset }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} + restore-keys: | + registry-evidence-dev-clients-${{ matrix.asset }}- + + - name: Build the Python client wheel + working-directory: crates/registry-evidence-client-py + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.95.0 --profile minimal + # `maturin` gets its own environment: the wheel is smoked below in a + # separate one, which must hold the wheel and nothing else. + python3 -m venv "${RUNNER_TEMP}/maturin" + "${RUNNER_TEMP}/maturin/bin/pip" install --quiet maturin==1.9.6 + # The version `maturin` stamps into the wheel comes from this file's + # own `[project] version`, hand-synced with the workspace version, so + # the development version has to be written here before the build. + awk \ + -v want='version = "${{ needs.validate.outputs.version }}"' \ + -v line='version = "${{ needs.validate.outputs.python_version }}"' ' + $0 == want && !rewritten { print line; rewritten = 1; next } + { print } + END { if (!rewritten) exit 1 } + ' pyproject.toml > "${RUNNER_TEMP}/pyproject.toml" + cp "${RUNNER_TEMP}/pyproject.toml" pyproject.toml + wheel_dir="${GITHUB_WORKSPACE}/development-clients" + mkdir -p "${wheel_dir}" + # `--compatibility linux` on Linux, rather than maturin's default + # manylinux audit: the audit picks a glibc floor from the symbols the + # compiled extension happens to need, so its platform tag is not + # predictable from the source, and this workflow publishes a closed + # roster of exactly named assets. The cost is that a Linux wheel + # needs the build runner's glibc or newer, which the release notes + # state. + if [[ "${RUNNER_OS}" == Linux ]]; then + "${RUNNER_TEMP}/maturin/bin/maturin" build \ + --release --locked --compatibility linux --out "${wheel_dir}" + else + "${RUNNER_TEMP}/maturin/bin/maturin" build \ + --release --locked --out "${wheel_dir}" + fi + wheel="registry_evidence_client-${{ needs.validate.outputs.python_version }}-${{ matrix.wheel_tag }}.whl" + if [[ ! -f "${wheel_dir}/${wheel}" ]]; then + echo "maturin did not produce ${wheel}" >&2 + find "${wheel_dir}" -maxdepth 1 -name '*.whl' >&2 + exit 1 + fi + if [[ "$(find "${wheel_dir}" -maxdepth 1 -name '*.whl' | wc -l)" -ne 1 ]]; then + echo "expected exactly one wheel for ${{ matrix.asset }}" >&2 + exit 1 + fi + + - name: Smoke the Python client wheel + shell: bash + run: | + set -euo pipefail + wheel="registry_evidence_client-${{ needs.validate.outputs.python_version }}-${{ matrix.wheel_tag }}.whl" + python3 -m venv "${RUNNER_TEMP}/wheel-smoke" + "${RUNNER_TEMP}/wheel-smoke/bin/pip" install --quiet \ + "${GITHUB_WORKSPACE}/development-clients/${wheel}" + cat > "${RUNNER_TEMP}/wheel-smoke.py" <<'SMOKE' + """Proves the published wheel's compiled extension loads and works on + this platform, which is the failure a prebuilt native artifact + actually has. Behavioural coverage is the crate's own suite's job, in + ordinary CI; nothing here sends a request or touches the network.""" + + import registry_evidence_client as client_module + + JWKS = { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": "development-build-smoke-key", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + } + ] + } + SPEC = { + "requirement": "urn:example:requirement:v1", + "purpose": "example-purpose", + "audience": "urn:example:audience", + "evidence_type": "urn:example:evidence-type:v1", + "issued_by": "urn:example:issuer", + "provided_by": "urn:example:provider", + "configuration_revision": "sha256:" + "0" * 64, + "expected_assurance_profile": "local", + "subjects": [{"role": "subject", "selector_profile": "national-id"}], + "expected_outputs": [ + {"concept": "urn:example:concept:status-holds", "form": "boolean"} + ], + "maximum_assertion_lifetime_seconds": 300, + "clock_skew_seconds": 60, + "subject_expectations": "accept_first_use", + } + + # A placeholder static token: no request is ever sent with it. + client = client_module.EvidenceClient( + "https://evidence.invalid", JWKS, "placeholder-not-a-credential" + ) + prepared = client.prepare(SPEC) + if len(prepared.request_nonce) != 43: + raise SystemExit(f"unexpected nonce length {len(prepared.request_nonce)}") + if prepared.policy_document["audience"] != SPEC["audience"]: + raise SystemExit("the prepared policy does not carry the requested audience") + if prepared.subject_expectations != "accept_first_use": + raise SystemExit("the prepared request lost its subject expectations") + + try: + client.prepare(dict(SPEC, configuration_revision="")) + except client_module.ConfigurationError as error: + if error.kind != "configuration": + raise SystemExit(f"unexpected error kind {error.kind!r}") from error + else: + raise SystemExit("an empty configuration revision must be refused") + + print("wheel smoke passed") + SMOKE + "${RUNNER_TEMP}/wheel-smoke/bin/python" "${RUNNER_TEMP}/wheel-smoke.py" + + - name: Build the Node client package + working-directory: crates/registry-evidence-client-node + shell: bash + run: | + set -euo pipefail + npm ci + # Before the build, not after: `napi build` regenerates `index.js`, + # which embeds the package version for its own fallback check, so + # bumping first is what keeps the packed tree internally consistent. + npm version "${{ needs.validate.outputs.node_version }}" \ + --no-git-tag-version --allow-same-version + npm run build + npm pack --pack-destination "${RUNNER_TEMP}" + packed="${RUNNER_TEMP}/registrystack-evidence-client-${{ needs.validate.outputs.node_version }}.tgz" + if [[ ! -f "${packed}" ]]; then + echo "npm pack did not produce ${packed}" >&2 + exit 1 + fi + # `npm pack` names a tarball after the package, not the platform, and + # this one carries a platform-specific compiled binding, so the + # published name has to say which platform it is for. The binding + # itself is asserted present: `package.json`'s `files` list is what + # puts it there, and a silent drift would ship a package that cannot + # load anywhere. + # The listing is captured rather than piped into `grep -q`: an early + # `-q` exit kills `tar` with SIGPIPE, and under `pipefail` that 141 + # reads as a failed check on a tarball that is in fact correct. + entries="$(tar -tzf "${packed}")" + if ! grep -Fxq "package/evidence-client.${{ matrix.napi_platform }}.node" \ + <<<"${entries}"; then + echo "the packed tarball has no evidence-client.${{ matrix.napi_platform }}.node" >&2 + printf '%s\n' "${entries}" >&2 + exit 1 + fi + mkdir -p "${GITHUB_WORKSPACE}/development-clients" + cp "${packed}" \ + "${GITHUB_WORKSPACE}/development-clients/evidence-client-node-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}.tgz" + + - name: Smoke the Node client package + shell: bash + run: | + set -euo pipefail + tarball="${GITHUB_WORKSPACE}/development-clients/evidence-client-node-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}.tgz" + smoke="${RUNNER_TEMP}/node-smoke" + mkdir -p "${smoke}" + cd "${smoke}" + npm init --yes >/dev/null + npm install --no-audit --no-fund "${tarball}" + cat > smoke.js <<'SMOKE' + // Proves the published tarball's compiled binding loads and works on + // this platform, which is the failure a prebuilt native artifact + // actually has. Behavioural coverage is the crate's own suite's job, + // in ordinary CI; nothing here sends a request or touches the + // network. + 'use strict'; + + const assert = require('node:assert'); + const { EvidenceClient, EvidenceClientError } = require('@registrystack/evidence-client'); + + const trustedJwks = { + keys: [ + { + kty: 'OKP', + crv: 'Ed25519', + alg: 'EdDSA', + kid: 'development-build-smoke-key', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + ], + }; + const spec = { + requirement: 'urn:example:requirement:v1', + purpose: 'example-purpose', + audience: 'urn:example:audience', + evidenceType: 'urn:example:evidence-type:v1', + issuedBy: 'urn:example:issuer', + providedBy: 'urn:example:provider', + configurationRevision: `sha256:${'0'.repeat(64)}`, + expectedAssuranceProfile: 'local', + subjects: [{ role: 'subject', selectorProfile: 'national-id' }], + expectedOutputs: [{ concept: 'urn:example:concept:status-holds', form: 'boolean' }], + maximumAssertionLifetimeSeconds: 300, + clockSkewSeconds: 60, + subjectExpectations: 'acceptFirstUse', + }; + + const client = new EvidenceClient({ + baseUrl: 'https://evidence.invalid', + trustedJwks, + // A placeholder static token: no request is ever sent with it. + token: { static: 'placeholder-not-a-credential' }, + }); + const prepared = client.prepare(spec); + assert.strictEqual(prepared.requestNonce.length, 43); + assert.strictEqual(prepared.policyDocument.audience, spec.audience); + + assert.throws( + () => client.prepare({ ...spec, configurationRevision: '' }), + (error) => error instanceof EvidenceClientError && error.kind === 'configuration', + ); + + console.log('tarball smoke passed'); + SMOKE + node smoke.js + + - name: Upload Evidence development client packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: evidence-dev-clients-${{ matrix.asset }}-${{ github.run_id }}-${{ github.run_attempt }} + path: development-clients + if-no-files-found: error + retention-days: 2 + assemble: name: Assemble and smoke the curl-installable toolset needs: - validate - build + - clients runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -200,7 +497,7 @@ jobs: persist-credentials: false submodules: false - - name: Download exact native binaries + - name: Download exact native binaries and client packages uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: evidence-dev-*-${{ github.run_id }}-${{ github.run_attempt }} @@ -301,7 +598,16 @@ jobs: for binary in evidence evidencectl mint; do echo "${binary}-${tag}-${platform}" done + echo "evidence-client-node-${tag}-${platform}.tgz" done > "${RUNNER_TEMP}/expected-assets" + # One wheel per platform, not per Python version: `pyo3`'s + # `abi3-py310` feature is what makes that true, so a change to it + # fails this roster rather than quietly publishing wheels no + # documented installer command names. + python_version="${{ needs.validate.outputs.python_version }}" + for wheel_platform in linux_x86_64 linux_aarch64 macosx_11_0_arm64; do + echo "registry_evidence_client-${python_version}-cp310-abi3-${wheel_platform}.whl" + done >> "${RUNNER_TEMP}/expected-assets" { echo "evidencectl-${tag}-install.sh" echo evidencectl-install.sh @@ -343,7 +649,9 @@ jobs: set -euo pipefail tag="${{ needs.validate.outputs.tag }}" source_sha="${{ needs.validate.outputs.source_sha }}" - install_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/evidencectl-install.sh" + python_version="${{ needs.validate.outputs.python_version }}" + download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}" + install_url="${download_url}/evidencectl-install.sh" notes="${RUNNER_TEMP}/evidence-development-release-notes.md" { echo "Development build from protected-main source \`${source_sha}\`." @@ -351,10 +659,30 @@ jobs: echo "This is an unsupported prerelease for development and evaluation." echo "Its checksums are not signed and it is not a Registry Stack release." echo + echo '## Toolset' + echo echo '```sh' echo "curl -fsSL \"${install_url}\" | bash" echo '```' echo + echo '## Relying-party clients' + echo + echo 'Neither client is published to PyPI or npm. Install one from this' + echo 'prerelease, substituting your own platform:' + echo + echo '```sh' + echo "pip install \"${download_url}/registry_evidence_client-${python_version}-cp310-abi3-linux_x86_64.whl\"" + echo "npm install \"${download_url}/evidence-client-node-${tag}-linux-amd64.tgz\"" + echo '```' + echo + echo 'One wheel covers CPython 3.10 and later. The wheels for the other' + echo "platforms end in \`linux_aarch64\` or \`macosx_11_0_arm64\`, and the" + echo "Node packages in \`linux-arm64\` or \`macos-arm64\`." + echo + echo 'The Linux wheels are built on Ubuntu 24.04 and need its glibc or' + echo 'newer. The Rust client is not published here: consume it as a git' + echo 'dependency pinned to the source commit above.' + echo echo "Workflow run: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" } > "${notes}" gh release create "${tag}" development-assets/* \ @@ -369,6 +697,8 @@ jobs: echo echo '```sh' echo "curl -fsSL \"${install_url}\" | bash" + echo "pip install \"${download_url}/registry_evidence_client-${python_version}-cp310-abi3-linux_x86_64.whl\"" + echo "npm install \"${download_url}/evidence-client-node-${tag}-linux-amd64.tgz\"" echo '```' echo echo "Source: \`${source_sha}\`" diff --git a/crates/registry-evidence-client-py/README.md b/crates/registry-evidence-client-py/README.md index b82c6d828..1c39161d1 100644 --- a/crates/registry-evidence-client-py/README.md +++ b/crates/registry-evidence-client-py/README.md @@ -176,4 +176,10 @@ then copies the resulting dylib to a scratch directory as `registry_evidence_client.so` and puts that directory on `sys.path`, so the suite never depends on `maturin`, `pip install -e`, or any packaging step. -`maturin` itself is a local-development convenience only; CI never invokes it. +No test path invokes `maturin`, on a developer's machine or in CI. Two things +do: `uv run maturin develop` above, for local development, and +`.github/workflows/evidence-dev.yml`, which builds the wheel published with +each Evidence development prerelease and smokes it from a fresh virtual +environment. That workflow stamps the prerelease version into `pyproject.toml` +before building, so this crate's own copy of the workspace version stays the +released one. From 86ce809bae7207fe9cf941cef2bcf0c340100df6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 12:22:19 +0700 Subject: [PATCH 47/67] fix(evidence): search the verifier dependency tree without ripgrep The portability gate searched `cargo tree` output with `rg`, which the hosted runner that gates it does not have. The step failed outright, and the same absence in a construct that only tells a match from no match would have read as a clean tree instead. `grep -E` takes the same expression and is present everywhere the gate runs, so the three documented exit statuses keep their meanings. Signed-off-by: Jeremi Joslin --- products/evidence/scripts/check-verifier-portability.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/products/evidence/scripts/check-verifier-portability.sh b/products/evidence/scripts/check-verifier-portability.sh index e5ae3584e..c0f013023 100755 --- a/products/evidence/scripts/check-verifier-portability.sh +++ b/products/evidence/scripts/check-verifier-portability.sh @@ -38,9 +38,12 @@ for package in "${forbidden_packages[@]}"; do # A search that neither matches nor reports "no match" is a broken check, not # a clean tree, so separate the two outcomes from every other status. search_status=0 + # `grep -E` rather than ripgrep: the hosted runner that gates this has no + # ripgrep, and a search command that is absent exits 127, which reads as a + # clean tree in any construct that only distinguishes match from no match. matches=$( printf '%s\n' "$dependency_tree" | - rg "(^|[^0-9A-Za-z_-])${package}([-_][0-9A-Za-z_-]+)* v[0-9]" + grep -E "(^|[^0-9A-Za-z_-])${package}([-_][0-9A-Za-z_-]+)* v[0-9]" ) || search_status=$? case "$search_status" in 0) From b4ab79018317a8a4b8b5b1d54a7f9d46dfbe97b3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 12:22:19 +0700 Subject: [PATCH 48/67] test(evidence): assert the development build's client packages The development workflow gained a `clients` job, so the structure test's job list, and its assertion on the notes' installer URL, no longer described the workflow. Restate both, and cover what the job now owes the closed asset roster: the three platform legs, a wheel name predictable from the source, a platform-specific binding inside the packed tarball, and two offline smokes that carry no credential. Signed-off-by: Jeremi Joslin --- .../test_release_workflow_structure.py | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index e540a4657..6e08cdb15 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -58,12 +58,16 @@ def test_is_manual_main_only_with_one_narrow_publication_job(self) -> None: self.assertNotIn("schedule:", trigger) self.assertEqual( list(document["jobs"]), - ["validate", "build", "assemble", "publish"], + ["validate", "build", "clients", "assemble", "publish"], ) self.assertEqual( document["jobs"]["validate"]["permissions"], {"actions": "read", "contents": "read"}, ) + self.assertEqual( + document["jobs"]["clients"]["permissions"], + {"contents": "read"}, + ) self.assertEqual( document["jobs"]["assemble"]["permissions"], {"actions": "read", "contents": "read"}, @@ -148,6 +152,57 @@ def test_builds_and_smokes_the_released_toolset_shape(self) -> None: self.assertIn("EVIDENCECTL_ASSET_DIR", smoke) self.assertIn("bash development-assets/evidencectl-install.sh", smoke) + def test_builds_and_smokes_the_relying_party_client_packages(self) -> None: + _, document = workflow("evidence-dev.yml") + matrix = document["jobs"]["clients"]["strategy"]["matrix"]["include"] + self.assertEqual( + { + (entry["asset"], entry["wheel_tag"], entry["napi_platform"]) + for entry in matrix + }, + { + ("linux-amd64", "cp310-abi3-linux_x86_64", "linux-x64-gnu"), + ("linux-arm64", "cp310-abi3-linux_aarch64", "linux-arm64-gnu"), + ("macos-arm64", "cp310-abi3-macosx_11_0_arm64", "darwin-arm64"), + }, + ) + + wheel = step_run(document, "clients", "Build the Python client wheel") + # The published wheel name has to be predictable from the source, since + # the roster below names it exactly: hence a stated Linux platform tag + # instead of maturin's symbol-derived manylinux audit, and exactly one + # wheel per platform rather than one per interpreter version. + self.assertIn("--compatibility linux", wheel) + self.assertIn("expected exactly one wheel", wheel) + node = step_run(document, "clients", "Build the Node client package") + self.assertIn( + "package/evidence-client.${{ matrix.napi_platform }}.node", + node, + ) + + # Both smokes load a prebuilt native artifact and exercise it offline. + # Neither may carry a credential, and each addresses a reserved host + # that cannot resolve, so a request would fail rather than leave. + for name in ("Smoke the Python client wheel", "Smoke the Node client package"): + smoke = step_run(document, "clients", name) + self.assertIn("placeholder-not-a-credential", smoke) + self.assertIn("https://evidence.invalid", smoke) + + roster = step_run( + document, + "publish", + "Reverify the closed development asset roster", + ) + self.assertIn('echo "evidence-client-node-${tag}-${platform}.tgz"', roster) + self.assertIn( + 'echo "registry_evidence_client-${python_version}-cp310-abi3-${wheel_platform}.whl"', + roster, + ) + self.assertIn( + "for wheel_platform in linux_x86_64 linux_aarch64 macosx_11_0_arm64", + roster, + ) + def test_publishes_one_unique_prerelease_and_prints_its_curl_command(self) -> None: text, document = workflow("evidence-dev.yml") verify = step_run( @@ -166,7 +221,11 @@ def test_publishes_one_unique_prerelease_and_prints_its_curl_command(self) -> No self.assertIn('--target "${source_sha}"', publish) self.assertIn("--prerelease", publish) self.assertIn("--latest=false", publish) - self.assertIn("releases/download/${tag}/evidencectl-install.sh", publish) + self.assertIn( + 'download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}"', + publish, + ) + self.assertIn('install_url="${download_url}/evidencectl-install.sh"', publish) for forbidden in ( "gh release upload", "gh release delete", From 2d542eb31c242261ffbe3b308fa8f242246f221f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 12:22:19 +0700 Subject: [PATCH 49/67] test(evidence): look up stub routes through a map The stub server resolved a route by indexing the caller's plain object with a request line, so a request could reach an inherited `Object.prototype` member instead of a stub route. Hold the routes in a `Map` and require a function, which answers 404 for anything unrouted. Signed-off-by: Jeremi Joslin --- .../__test__/helpers/stub-server.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence-client-node/__test__/helpers/stub-server.js b/crates/registry-evidence-client-node/__test__/helpers/stub-server.js index 98f066385..3988f3e13 100644 --- a/crates/registry-evidence-client-node/__test__/helpers/stub-server.js +++ b/crates/registry-evidence-client-node/__test__/helpers/stub-server.js @@ -13,14 +13,18 @@ const http = require('node:http'); */ function startStubServer(routes) { const requests = []; + // A Map, not the caller's object: the lookup key carries a request line the + // test client controls, and a plain object would let one resolve to an + // inherited `Object.prototype` member instead of a stub route. + const table = new Map(Object.entries(routes)); const server = http.createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); requests.push({ method: req.method, url: req.url, headers: req.headers, body }); - const handler = routes[`${req.method} ${req.url}`]; - if (!handler) { + const handler = table.get(`${req.method} ${req.url}`); + if (typeof handler !== 'function') { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no stub route for this request'); return; From e3e7e17cf6a0a204c1c4e222fc9693e4b738784c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 12:44:57 +0700 Subject: [PATCH 50/67] fix(evidence): mark the Python client package as typed A PEP 561 checker ignores a committed stub in a package with no py.typed marker, so every installed client API resolved to Any and the __init__.pyi drift test protected nothing a consumer could see. Verified against a built wheel: the marker ships beside the stub and the compiled submodule. Signed-off-by: Jeremi Joslin --- .../python/registry_evidence_client/py.typed | 0 .../tests/python/test_drift.py | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 crates/registry-evidence-client-py/python/registry_evidence_client/py.typed diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/py.typed b/crates/registry-evidence-client-py/python/registry_evidence_client/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/crates/registry-evidence-client-py/tests/python/test_drift.py b/crates/registry-evidence-client-py/tests/python/test_drift.py index a245d3f5f..8a8ddc77c 100644 --- a/crates/registry-evidence-client-py/tests/python/test_drift.py +++ b/crates/registry-evidence-client-py/tests/python/test_drift.py @@ -28,9 +28,8 @@ import unittest _TESTS_DIR = pathlib.Path(__file__).resolve().parent -_STUB_PATH = ( - _TESTS_DIR.parent.parent / "python" / "registry_evidence_client" / "__init__.pyi" -) +_PACKAGE_DIR = _TESTS_DIR.parent.parent / "python" / "registry_evidence_client" +_STUB_PATH = _PACKAGE_DIR / "__init__.pyi" sys.path.insert(0, str(_TESTS_DIR)) sys.path.insert(0, str(_TESTS_DIR / "helpers")) @@ -145,6 +144,16 @@ def test_every_exception_subclass_is_declared_and_live_under_the_base(self): live_cls = getattr(revc, name) self.assertTrue(issubclass(live_cls, revc.EvidenceClientError)) + def test_the_package_ships_a_pep_561_marker_beside_the_stub(self): + # A PEP 561 checker ignores `__init__.pyi` in an installed package that + # carries no `py.typed`, which reports every client API as `Any` and + # makes the stub above, and this whole test, invisible to consumers. + marker = _PACKAGE_DIR / "py.typed" + self.assertTrue( + marker.is_file(), + f"{marker} must exist so the committed stub is honored once installed", + ) + def test_the_base_exception_is_declared_and_live_as_an_exception(self): self.assertEqual( _stub_base_names(self.stub_classes["EvidenceClientError"]), {"Exception"} From ebfa7d0bcbd4ad54e1bb1996d939a1c3c6c98221 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 12:48:47 +0700 Subject: [PATCH 51/67] fix(evidence): select the binding job from reverse dependents Both bindings are Cargo path dependents of the client SDK and the verifier, so either can move the native surface or the error envelope the packages wrap. Selecting the job from changed paths alone skipped the npm suite, the type-drift check, and the Python unittest suite for exactly the changes most able to break them, and ci-result treats a skipped job as passing. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 10 +++++----- .github/scripts/test_ci_changes.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 410011075..de936e904 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -482,11 +482,11 @@ def classify( for path in paths ) editors = complete or any(path.startswith("editors/") for path in paths) - client_bindings = complete or any( - path.startswith(f"crates/{package}/") - for path in paths - for package in EVIDENCE_BINDING_PACKAGES - ) + # Reverse dependents, not changed paths: both bindings are Cargo path + # dependents of the SDK and the verifier, so a change to either can move + # the native surface or the error envelope the packages wrap without + # touching a file inside a binding crate. + client_bindings = complete or bool(affected & EVIDENCE_BINDING_PACKAGES) tutorial_infrastructure = any( path diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 9731ca81c..9f7899583 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -237,6 +237,24 @@ def test_binding_only_change_runs_contracts_but_not_the_tutorial_job(self) -> No {"evidence"}, ) + def test_an_sdk_or_verifier_change_also_runs_the_binding_job(self) -> None: + # Both bindings are Cargo path-dependents of the SDK and the verifier, + # so either can change the native surface or the error envelope the + # packages wrap. Selecting the job from changed paths alone would skip + # the npm suite, the type-drift check, and the Python unittest suite for + # exactly the changes most able to break them. + for path in ( + "crates/registry-evidence-client/src/client.rs", + "crates/registry-evidence-verifier/src/lib.rs", + ): + with self.subTest(path=path): + outputs = classify(self.workspace, (path,)) + self.assertTrue(outputs["client_bindings"]) + self.assertIn( + "registry-evidence-client-node", outputs["rust_packages"] + ) + self.assertIn("registry-evidence-client-py", outputs["rust_packages"]) + def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertIn("\n evidence-contracts:\n", workflow) From 0ab3134ed5ebfa5acba619d7d95d4011324baf9e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 12:52:52 +0700 Subject: [PATCH 52/67] fix(evidence): refuse an over-specified Node token config A `token` object naming both `static` and `privateKeyJwt`, or carrying a stray key beside a real provider, selected whichever branch was tested first. A botched merge of two authentication configurations, or a misspelled provider name left beside a real one, would then run with a credential the caller did not choose. Require exactly one provider. Signed-off-by: Jeremi Joslin --- .../src/convert.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index 251b2546e..d757a8ae6 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -368,6 +368,15 @@ fn token_provider_from_json( .ok_or_else(|| ConversionError::new("`token` must be present")) .map_err(ConfigError::Shape)?; let token_object = as_object(token, "`token`").map_err(ConfigError::Shape)?; + // Before dispatching on which provider is named: selecting the first one + // present would let a merge of two authentication configurations, or a + // misspelled key left beside a real one, run with a credential the caller + // did not choose. + if token_object.len() != 1 { + return Err(ConfigError::Shape(ConversionError::new( + "`token` must carry exactly one of `static` or `privateKeyJwt`", + ))); + } if let Some(value) = token_object.get("static") { let value = value @@ -835,6 +844,40 @@ mod tests { config_from_json(&config_json).expect("the config converts"); } + #[test] + fn a_token_naming_more_than_one_provider_is_a_shape_error() { + // Selecting the first provider present would let a botched merge of two + // authentication configurations run with a credential the caller did + // not mean to send, so an over-specified `token` fails closed. + let config_json = serde_json::json!({ + "baseUrl": "https://evidence.example.org", + "trustedJwks": one_key_jwks_json(), + "token": { + "static": "header-safe-token", + "privateKeyJwt": { + "tokenEndpoint": "https://issuer.example.org/token", + "clientId": "example-client", + "clientKey": generated_client_key_json("signing-key-1"), + "audience": "https://issuer.example.org/", + }, + }, + }); + assert!(matches!( + config_from_json(&config_json), + Err(ConfigError::Shape(_)) + )); + } + + #[test] + fn a_token_carrying_a_stray_key_beside_a_provider_is_a_shape_error() { + let mut config = valid_config_json_with_static_token(); + config["token"]["privateKyeJwt"] = Value::Null; + assert!(matches!( + config_from_json(&config), + Err(ConfigError::Shape(_)) + )); + } + #[test] fn a_missing_trusted_jwks_is_a_shape_error() { let mut config = valid_config_json_with_static_token(); From 4fb3344976c1a919236cf844e46ed90d7782f4af Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:34:36 +0700 Subject: [PATCH 53/67] fix(evidence): honor only the problem codes the contract registers The frozen problem contract registers nine (status, code) pairs, and the status belongs to the pair: a code is registered for one status, and two codes may share one. Mapping whatever code arrived under whatever status let an unregistered code, or a registered one under a status it was never registered for, reach a caller as a refusal it could act on, and put a deployment-chosen string in `code`. Such a body is a promise the deployment never made, so it now becomes an uninterpreted protocol failure carrying no code and no retry hint. `type` and `title` stay unvalidated on purpose: the contract does not pin the `type` URI (only generated OpenAPI does), and `title` is human-facing text a deployment may word or localize as it chooses. Review note: this only narrows which bodies become a refusal. No body that was previously uninterpreted can now be read as one. Signed-off-by: Jeremi Joslin --- .../tests/python/test_errors.py | 4 +- .../registry-evidence-client/src/problem.rs | 79 ++++++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/crates/registry-evidence-client-py/tests/python/test_errors.py b/crates/registry-evidence-client-py/tests/python/test_errors.py index 71ca0c9d3..053ceede2 100644 --- a/crates/registry-evidence-client-py/tests/python/test_errors.py +++ b/crates/registry-evidence-client-py/tests/python/test_errors.py @@ -101,14 +101,14 @@ def test_400_with_an_ordinary_code_maps_to_protocol(self): self.server.routes["POST /v1/evidence"] = StubRoute( status=400, headers={"Content-Type": PROBLEM_MEDIA_TYPE}, - body=fixtures.problem_body(400, "bad_request"), + body=fixtures.problem_body(400, "malformed_request"), ) with self.assertRaises(revc.ProtocolError) as raised: self._send(self._client()) error = raised.exception self.assertEqual(error.kind, "protocol") self.assertEqual(error.status, 400) - self.assertEqual(error.code, "bad_request") + self.assertEqual(error.code, "malformed_request") def test_a_success_with_the_wrong_media_type_maps_to_protocol(self): self.server.routes["POST /v1/evidence"] = StubRoute( diff --git a/crates/registry-evidence-client/src/problem.rs b/crates/registry-evidence-client/src/problem.rs index f9fc803c1..2c4d35ffb 100644 --- a/crates/registry-evidence-client/src/problem.rs +++ b/crates/registry-evidence-client/src/problem.rs @@ -19,6 +19,24 @@ const EVIDENCE_NOT_AVAILABLE: &str = "evidence_not_available"; /// Longest accepted problem body. The closed contract is far smaller. pub(crate) const MAXIMUM_PROBLEM_BYTES: usize = 4 * 1024; +/// Every `(status, code)` pair the frozen problem contract registers, as +/// `products/evidence/contracts/problem-contract.yaml` states them. The status +/// belongs to the pair: one code may be registered for one status only, and two +/// codes may share a status. `type` and `title` are deliberately absent, because +/// the contract does not pin the `type` URI and `title` is human facing text a +/// deployment may word or localize as it chooses. +const REGISTERED_PROBLEMS: [(u16, &str); 9] = [ + (400, "malformed_request"), + (400, "invalid_selector"), + (401, "authentication_failed"), + (403, "not_authorized"), + (406, "response_format_not_acceptable"), + (422, EVIDENCE_NOT_AVAILABLE), + (429, "rate_limited"), + (503, "dependency_unavailable"), + (503, "service_unavailable"), +]; + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub(crate) struct ProblemBody { @@ -59,19 +77,29 @@ pub(crate) fn map_problem( }; let operation = sanitized_operation(&problem.operation).or_else(|| header_operation.map(str::to_owned)); - match (status, problem.code.as_str()) { - (401 | 403 | 429, code) => EvidenceClientError::Denied { + // The code is honored only under a status the contract registered it for. + // A code the contract never registered, or a registered one arriving under + // another status, is a body the deployment did not promise, so it becomes an + // uninterpreted protocol failure rather than a refusal a caller could act on. + let code = REGISTERED_PROBLEMS + .iter() + .find(|(registered_status, registered_code)| { + *registered_status == status && *registered_code == problem.code + }) + .map(|(_, registered_code)| *registered_code); + match (status, code) { + (401 | 403 | 429, Some(code)) => EvidenceClientError::Denied { status, code: code.to_owned(), operation, retry_after_seconds: retry_after_seconds.filter(|_| status == 429), }, - (422, EVIDENCE_NOT_AVAILABLE) => EvidenceClientError::NotAvailable { operation }, + (422, Some(EVIDENCE_NOT_AVAILABLE)) => EvidenceClientError::NotAvailable { operation }, (_, code) => EvidenceClientError::Protocol { status, - code: Some(code.to_owned()), + code: code.map(str::to_owned), operation, - retry_after_seconds: retry_after_seconds.filter(|_| status == 503), + retry_after_seconds: retry_after_seconds.filter(|_| status == 503 && code.is_some()), }, } } @@ -195,9 +223,6 @@ mod tests { (406, "response_format_not_acceptable"), (503, "dependency_unavailable"), (503, "service_unavailable"), - // A 422 that is not the collapsed answer is not something this - // client can interpret. - (422, "malformed_request"), ] { let mapped = map_problem( status, @@ -218,6 +243,42 @@ mod tests { } } + /// The contract registers nine `(status, code)` pairs. A code that is merely + /// shaped like one of them, or a registered code arriving under a status it + /// was never registered for, is not something this client can explain, so it + /// must not become a refusal or a reported code. + #[test] + fn a_code_outside_the_registered_problem_set_is_never_read_as_a_refusal() { + for (status, code) in [ + // Lexically valid, entirely unregistered. + (403_u16, "custom_failure"), + // Registered, but under another status. + (429, "not_authorized"), + (400, "rate_limited"), + (422, "malformed_request"), + ] { + let mapped = map_problem( + status, + Some(PROBLEM_MEDIA_TYPE), + &problem_json(status, code), + Some(30), + None, + ); + assert_eq!( + mapped, + EvidenceClientError::Protocol { + status, + code: None, + // The identifier still survives: it is the one member the + // contract calls safe for support correlation, and losing it + // would leave an adopter nothing to quote. + operation: Some(OPERATION.to_owned()), + retry_after_seconds: None, + } + ); + } + } + /// The contract permits a bounded wait on a transient failure, which is the /// answer a relying party can politely back off from. #[test] @@ -227,8 +288,8 @@ mod tests { (503, "service_unavailable", Some(30)), // Nothing else in the coarse mapping surfaces a wait. (400, "malformed_request", None), + (400, "invalid_selector", None), (406, "response_format_not_acceptable", None), - (422, "malformed_request", None), ] { let mapped = map_problem( status, From 67ec1b399401ef9e90131b69eb2fcfef815b9e02 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:34:49 +0700 Subject: [PATCH 54/67] fix(evidence): withhold a single definition for an ambiguous requirement A deployment keys its authorized shapes on requirement, purpose, and selector profile together, so one requirement identifier may carry several definitions, and two such entries are distinct items the contract's `uniqueItems` does not stop. `definition()` answered with whichever shape happened to serialize first, which had a relying party author a request, and close a verification policy, for a purpose it never chose. Nothing downstream would catch it: verification passes, because the deployment did issue for that purpose. It now answers `None` when more than one shape matches. `definitions` stays public, so a caller that wants to disambiguate on purpose or selector profile still can. Signed-off-by: Jeremi Joslin --- .../src/definitions.rs | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence-client/src/definitions.rs b/crates/registry-evidence-client/src/definitions.rs index eeb45383e..d84d3d7a5 100644 --- a/crates/registry-evidence-client/src/definitions.rs +++ b/crates/registry-evidence-client/src/definitions.rs @@ -34,11 +34,21 @@ pub struct EvidenceDefinitionsDocument { impl EvidenceDefinitionsDocument { /// The single definition for one requirement identifier, when the /// requester is entitled to exactly one shape of it. + /// + /// A deployment keys its authorized shapes on requirement, purpose, and + /// selector profile together, so one requirement may carry several. This + /// answers `None` for an ambiguous requirement rather than picking one, + /// because `purpose` is policy bearing on both ends: it travels in the + /// request and the verifier compares it. Read `definitions` directly to + /// choose between the shapes. #[must_use] pub fn definition(&self, requirement: &str) -> Option<&EvidenceDefinition> { - self.definitions + let mut matching = self + .definitions .iter() - .find(|definition| definition.requirement == requirement) + .filter(|definition| definition.requirement == requirement); + let first = matching.next()?; + matching.next().is_none().then_some(first) } } @@ -305,6 +315,36 @@ mod tests { .is_none()); } + #[test] + fn a_requirement_with_two_authorized_shapes_yields_no_single_definition() { + // A deployment keys its discovery candidates on requirement, purpose, + // and selector profile together, so one requirement can carry several + // authorized shapes. Answering with whichever happened to serialize + // first would have the relying party author a request, and close a + // verification policy, for a purpose it never chose, and verification + // would pass because the deployment did issue for that purpose. + let mut document = document(); + let mut other_purpose = document.definitions[0].clone(); + other_purpose.purpose = "other-decision".to_owned(); + document.definitions.push(other_purpose); + // Two shapes of one requirement are distinct items, so the definitions + // contract's `uniqueItems` does not stop a deployment sending this. + let round_tripped: EvidenceDefinitionsDocument = serde_json::from_str( + &serde_json::to_string(&document).expect("the two shape document serializes"), + ) + .expect("the two shape document parses"); + assert_eq!(round_tripped.definitions.len(), 2); + assert!(round_tripped + .definition("urn:example:client:requirement:status:v1") + .is_none()); + // The entries stay visible, so a caller that wants to disambiguate on + // purpose or selector profile still can. + assert!(round_tripped + .definitions + .iter() + .any(|definition| definition.purpose == "other-decision")); + } + #[test] fn an_undeclared_member_is_refused() { let extended = DOCUMENT.replace( From dbe5d143cd5f76a9f4e75723ce24152d502168f1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:34:49 +0700 Subject: [PATCH 55/67] fix(evidence): spend a cached client credential on a monotonic deadline The cached access token's deadline was a wall-clock instant, so correcting the host clock backwards extended how long the client kept presenting one credential, past the lifetime the authorization server granted. The deadline is now a reading that only moves forward, which no clock adjustment can push out. That reading excludes machine suspend, where wall clock did not, so a host resuming from a long suspend may treat a credential that is still live as spent. That direction fails closed: the next caller acquires a replacement. `PrivateKeyJwtConfig`'s `Debug` also rendered the token endpoint verbatim, including any userinfo an integrator put in it. It now strips userinfo the same way the base URL rendering does, from the same helper, so the two cannot drift apart. Review note: authentication credential lifetime and credential redaction. Both changes fail closed. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client/src/outbound.rs | 21 +- .../src/private_key_jwt.rs | 179 +++++++++++++++--- 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/crates/registry-evidence-client/src/outbound.rs b/crates/registry-evidence-client/src/outbound.rs index a91b77205..a25cd3db3 100644 --- a/crates/registry-evidence-client/src/outbound.rs +++ b/crates/registry-evidence-client/src/outbound.rs @@ -5,7 +5,7 @@ //! signed client assertion. Both hand a secret to a host the integrator named, //! so both are built here rather than from two rule sets that could drift apart. -use std::time::Duration; +use std::{borrow::Cow, time::Duration}; use registry_platform_httputil::BoundedReadError; use url::Url; @@ -94,6 +94,25 @@ pub(crate) fn transport_protects_the_credential(url: &Url) -> bool { } } +/// The base URL with any userinfo removed. +/// +/// [`EvidenceClientConfig::validate`] refuses a base URL carrying credentials, +/// but it runs inside `EvidenceClient::new`, so the rendering cannot rely on +/// having been reached after construction. +pub(crate) fn base_url_without_userinfo(base_url: &Url) -> Cow<'_, str> { + if base_url.username().is_empty() && base_url.password().is_none() { + return Cow::Borrowed(base_url.as_str()); + } + let mut stripped = base_url.clone(); + // Both setters refuse only a URL that cannot carry userinfo at all, and this + // point is reached only for a URL that carries some, so neither can refuse + // here. A refusal withholds the whole URL rather than rendering a credential. + if stripped.set_username("").is_err() || stripped.set_password(None).is_err() { + return Cow::Borrowed(""); + } + Cow::Owned(stripped.into()) +} + /// Why a send failed, in the terms the caller can act on. pub(crate) fn send_failure_kind(error: &reqwest::Error) -> TransportKind { if error.is_timeout() { diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index c4dc3d8d3..b17fd4db1 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -18,9 +18,15 @@ //! at which point the next caller acquires a replacement. The margin exists //! because a credential that is valid when the request is built may have expired //! by the time the deployment reads it. A server that states no lifetime has given -//! nothing to cache against, so each request acquires its own credential. - -use std::{fmt, sync::Arc, time::Duration}; +//! nothing to cache against, so each request acquires its own credential. The +//! deadline is measured against a reading that only moves forward, so correcting +//! the host clock cannot extend how long a credential is presented for. + +use std::{ + fmt, + sync::Arc, + time::{Duration, Instant}, +}; use async_trait::async_trait; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -37,7 +43,9 @@ use zeroize::Zeroizing; use crate::{ config::{DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT}, - outbound::{self, transport_protects_the_credential, OutboundOptions}, + outbound::{ + self, base_url_without_userinfo, transport_protects_the_credential, OutboundOptions, + }, problem::essence, token::{BearerToken, OAuthErrorCode, TokenError, TokenProvider}, }; @@ -89,12 +97,19 @@ const BEARER_TOKEN_TYPE: &str = "bearer"; /// JSON object; anything larger is not one. const MAXIMUM_TOKEN_RESPONSE_BYTES: u64 = 16 * 1024; -/// The instant the provider reasons about. +/// The two readings the provider reasons about. /// -/// It exists so assertion claims and cache arithmetic are driven by one source a -/// test can move, rather than by two readings of the host clock. +/// They are separate because they answer different questions. An assertion claim +/// is a wall-clock time the authorization server checks against its own clock, +/// so nothing else will do there. A cache deadline is only ever compared against +/// a later reading of the same clock, and a wall-clock one would move whenever +/// the host clock is corrected. Both come from one source a test can drive, +/// rather than from readings of the host taken wherever they are needed. pub(crate) trait Clock: Send + Sync { fn unix_seconds(&self) -> i64; + + /// A reading that only ever moves forward, however the host clock is set. + fn monotonic(&self) -> Instant; } /// The host clock. @@ -104,6 +119,10 @@ impl Clock for SystemClock { fn unix_seconds(&self) -> i64 { Utc::now().timestamp() } + + fn monotonic(&self) -> Instant { + Instant::now() + } } /// What an integrator decides before the provider can authenticate. @@ -201,12 +220,16 @@ impl PrivateKeyJwtConfig { } impl fmt::Debug for PrivateKeyJwtConfig { - /// The client key and the pinned certificate material are withheld. Only the - /// operational choices and the public identifiers are rendered. + /// The client key and the pinned certificate material are withheld, as is any + /// userinfo in the token endpoint. Only the operational choices and the public + /// identifiers are rendered. fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("PrivateKeyJwtConfig") - .field("token_endpoint", &self.token_endpoint.as_str()) + .field( + "token_endpoint", + &base_url_without_userinfo(&self.token_endpoint), + ) .field("client_id", &self.client_id) .field("audience", &self.audience) .field( @@ -221,10 +244,10 @@ impl fmt::Debug for PrivateKeyJwtConfig { } } -/// An access token, and the instant it stops being worth presenting. +/// An access token, and the monotonic instant it stops being worth presenting. struct CachedToken { token: BearerToken, - expires_at: i64, + expires_at: Instant, } /// A [`TokenProvider`] that authenticates with a signed assertion and caches what @@ -334,11 +357,18 @@ impl PrivateKeyJwt { } /// The cached credential, if it has more life left than the refresh margin. - async fn usable_cached_token(&self, now: i64) -> Option { + /// + /// The deadline and `monotonic_now` are both readings of a clock that only + /// moves forward, so a host clock stepping backward cannot present a spent + /// credential as a fresh one. + async fn usable_cached_token(&self, monotonic_now: Instant) -> Option { + // A negative margin was refused at construction, so this is the margin + // the integrator configured. + let margin = Duration::from_secs(self.refresh_margin_seconds.unsigned_abs()); let cached = self.cached.read().await; cached .as_ref() - .filter(|entry| now.saturating_add(self.refresh_margin_seconds) < entry.expires_at) + .filter(|entry| entry.expires_at.saturating_duration_since(monotonic_now) > margin) .map(|entry| entry.token.clone()) } @@ -384,7 +414,11 @@ impl PrivateKeyJwt { } /// Exchange one fresh assertion for an access token. - async fn acquire(&self, now: i64) -> Result { + /// + /// `now` dates the assertion the authorization server validates, and + /// `monotonic_now` is what the cache deadline of whatever it issues is + /// measured from. + async fn acquire(&self, now: i64, monotonic_now: Instant) -> Result { let assertion = self.sign_assertion(now)?; // The assertion is a credential, so it lives in a scrubbed buffer here. // The body reqwest owns afterwards cannot be wiped, which is why the @@ -457,7 +491,10 @@ impl PrivateKeyJwt { .expires_in .filter(|seconds| *seconds > 0) .map(|seconds| seconds.min(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS)) - .map(|seconds| now.saturating_add(seconds)), + // What reaches this point is within + // 1..=MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, so the deadline is + // an instant at most a day ahead of the reading it is built on. + .map(|seconds| monotonic_now + Duration::from_secs(seconds.unsigned_abs())), }) } } @@ -465,7 +502,7 @@ impl PrivateKeyJwt { #[async_trait] impl TokenProvider for PrivateKeyJwt { async fn bearer_token(&self) -> Result { - if let Some(token) = self.usable_cached_token(self.clock.unix_seconds()).await { + if let Some(token) = self.usable_cached_token(self.clock.monotonic()).await { return Ok(token); } // The refresh lock serializes acquisition: one caller holds it and @@ -477,11 +514,12 @@ impl TokenProvider for PrivateKeyJwt { // it times the configured request timeout, not by that timeout alone. let _refreshing = self.refresh_lock.lock().await; let now = self.clock.unix_seconds(); - if let Some(token) = self.usable_cached_token(now).await { + let monotonic_now = self.clock.monotonic(); + if let Some(token) = self.usable_cached_token(monotonic_now).await { return Ok(token); } - let acquired = self.acquire(now).await?; + let acquired = self.acquire(now, monotonic_now).await?; let mut cached = self.cached.write().await; // An uncacheable credential clears the cache rather than leaving a stale // entry behind it. @@ -515,7 +553,7 @@ impl fmt::Debug for PrivateKeyJwt { /// A credential and what may be assumed about how long it lasts. struct AcquiredToken { token: BearerToken, - expires_at: Option, + expires_at: Option, } /// The success response of RFC 6749 section 5.1, in the members this client uses. @@ -564,10 +602,10 @@ mod tests { use std::{ net::TcpListener, sync::{ - atomic::{AtomicI64, Ordering}, + atomic::{AtomicI64, AtomicU64, Ordering}, Arc, }, - time::Duration, + time::{Duration, Instant}, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -598,21 +636,52 @@ mod tests { /// A clock a test moves by hand, so cache arithmetic is asserted rather than /// waited out. - struct TestClock(AtomicI64); + /// + /// Both readings move together under [`TestClock::set`], as they do on a host + /// whose clock nothing is correcting. A test that needs them to disagree, + /// which is what a correction looks like, moves the wall reading on its own. + struct TestClock { + unix_seconds: AtomicI64, + unix_origin: i64, + monotonic_origin: Instant, + monotonic_elapsed_seconds: AtomicU64, + } impl TestClock { fn new(now: i64) -> Self { - Self(AtomicI64::new(now)) + Self { + unix_seconds: AtomicI64::new(now), + unix_origin: now, + monotonic_origin: Instant::now(), + monotonic_elapsed_seconds: AtomicU64::new(0), + } } + /// Both readings are now at `now`, which is what time passing looks like. fn set(&self, now: i64) { - self.0.store(now, Ordering::Relaxed); + self.unix_seconds.store(now, Ordering::Relaxed); + let elapsed = u64::try_from(now - self.unix_origin) + .expect("a test moves this clock forward from where it started"); + self.monotonic_elapsed_seconds + .store(elapsed, Ordering::Relaxed); + } + + /// Move the wall reading back, leaving the monotonic reading where it is. + /// That is what an NTP correction, a virtual machine resume, or an + /// operator setting the clock by hand does to a running process. + fn step_wall_clock_backward(&self, seconds: i64) { + self.unix_seconds.fetch_sub(seconds, Ordering::Relaxed); } } impl Clock for TestClock { fn unix_seconds(&self) -> i64 { - self.0.load(Ordering::Relaxed) + self.unix_seconds.load(Ordering::Relaxed) + } + + fn monotonic(&self) -> Instant { + self.monotonic_origin + + Duration::from_secs(self.monotonic_elapsed_seconds.load(Ordering::Relaxed)) } } @@ -875,6 +944,41 @@ mod tests { ); } + /// A host clock steps backward for ordinary reasons: an NTP correction, a + /// virtual machine resuming, an operator setting it by hand. A credential + /// whose deadline was a wall-clock time would look fresh again for as long as + /// the step was wide, and the provider would keep presenting a credential the + /// authorization server has already expired. + #[tokio::test] + async fn a_cached_credential_is_not_reused_after_the_host_clock_steps_backward() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + assert_eq!(token_requests(&server).await, 1); + + // The whole stated lifetime really elapsed, so the credential is spent. + clock.set(NOW + TOKEN_LIFETIME_SECONDS); + // Then the host clock is corrected far enough backward that its reading + // is before the credential was issued at all. + clock.step_wall_clock_backward(TOKEN_LIFETIME_SECONDS * 2); + assert!( + clock.unix_seconds() < NOW, + "the correction lands before the credential was issued" + ); + + provider + .bearer_token() + .await + .expect("a replacement credential"); + assert_eq!( + token_requests(&server).await, + 2, + "a spent credential was replayed after the host clock stepped backward" + ); + } + /// A stated lifetime the issuer never bounded, such as `i64::MAX`, must not /// keep a credential cached for the life of the process. The provider clamps /// it to its own configured maximum before caching. @@ -1350,4 +1454,27 @@ mod tests { assert!(!rendered.contains(ISSUED_CREDENTIAL), "{rendered}"); assert!(rendered.contains(KEY_ID), "the key identifier is public"); } + + /// A caller can put userinfo in the token endpoint it names, and a rendering + /// that carried it through would print that credential wherever the + /// configuration is rendered: a wider `Debug`, a panic message, a tracing + /// field. + #[test] + fn debug_output_withholds_userinfo_the_caller_put_in_the_token_endpoint() { + let candidate = config( + endpoint("https://client:s3cr3t@issuer.example.org"), + client_key(Some(KEY_ID)), + ); + + let rendered = format!("{candidate:?}"); + + assert!(!rendered.contains("s3cr3t"), "{rendered}"); + // The separator is what makes a userinfo component one, and no other + // field of this rendering carries it, so its absence is what proves none + // was rendered under any spelling. + assert!(!rendered.contains('@'), "{rendered}"); + // The endpoint still has to be recognizable, or the rendering is no use + // for telling one misconfigured deployment from another. + assert!(rendered.contains("issuer.example.org/token"), "{rendered}"); + } } From 751b4436f3fba387ca6c2dec48bafdcf5e5865b2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:35:02 +0700 Subject: [PATCH 56/67] fix(evidence): bound the local verification policy the client writes `PreparedEvidenceRequest::new` refused only a zero assertion lifetime, so a caller could close a policy the deployment's own verification policy contract would reject: a lifetime past the contract's one-year ceiling, a clock skew past 300 seconds, or a list-form expected output whose cardinality sat outside 1..=64 or stated a minimum above its maximum. A minimum above its maximum can never be satisfied at all, so accepting it only deferred the failure to a place the caller could not diagnose. Each ceiling is now a named constant, pinned to the number its refusal message states by a compile-time assertion, so the two cannot drift. The tests exercise each edge from both sides: one step past is refused, and the edge itself stays accepted. Signed-off-by: Jeremi Joslin --- .../tests/happy_path.rs | 2 +- .../registry-evidence-client/src/prepare.rs | 125 +++++++++++++++++- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/crates/registry-evidence-client-py/tests/happy_path.rs b/crates/registry-evidence-client-py/tests/happy_path.rs index a42729cb2..21bc9dff3 100644 --- a/crates/registry-evidence-client-py/tests/happy_path.rs +++ b/crates/registry-evidence-client-py/tests/happy_path.rs @@ -438,7 +438,7 @@ fn a_stale_fixture_response_fails_verification_against_a_live_prepared_request() "expected_outputs": [ { "concept": "urn:example:concept:status-holds", "form": "boolean" } ], - "maximum_assertion_lifetime_seconds": 3650 * 24 * 60 * 60_i64, + "maximum_assertion_lifetime_seconds": 30 * 24 * 60 * 60_i64, "clock_skew_seconds": 30, "subject_expectations": "accept_first_use", }); diff --git a/crates/registry-evidence-client/src/prepare.rs b/crates/registry-evidence-client/src/prepare.rs index 23a8d8166..a28d6aaf1 100644 --- a/crates/registry-evidence-client/src/prepare.rs +++ b/crates/registry-evidence-client/src/prepare.rs @@ -14,7 +14,8 @@ use std::{ use registry_evidence_verifier::{ verifier::{ - EvidenceVerificationPolicyDocument, ExpectedOutputDocument, ExpectedSubjectDocument, + EvidenceVerificationPolicyDocument, ExpectedFormDocument, ExpectedOutputDocument, + ExpectedSubjectDocument, }, AssuranceProfile, }; @@ -46,6 +47,14 @@ pub const MAXIMUM_SELECTOR_STRING_BYTES: usize = 512; pub const MINIMUM_SELECTOR_INTEGER: i64 = -9_007_199_254_740_991; /// Largest selector integer the request contract accepts. pub const MAXIMUM_SELECTOR_INTEGER: i64 = 9_007_199_254_740_991; +/// Longest maximum assertion lifetime a policy may state, per the +/// verification policy contract the deployment applies. +pub const MAXIMUM_ASSERTION_LIFETIME_SECONDS: u64 = 31_536_000; +/// Largest clock skew tolerance a policy may state, per the same contract. +pub const MAXIMUM_CLOCK_SKEW_SECONDS: u64 = 300; +/// Largest list cardinality, minimum or maximum, a list-form expected output +/// may state, per the same contract. +pub const MAXIMUM_LIST_ITEMS: usize = 64; /// One requested subject, before the request body exists. #[derive(Debug, Clone)] @@ -370,6 +379,9 @@ fn validate(spec: &EvidenceRequestSpec) -> Result<(), EvidenceClientError> { )); } let mut concepts = BTreeSet::new(); + // Ties the message below to the constant, so the constant cannot drift + // from the number the message states. + const _: () = assert!(MAXIMUM_LIST_ITEMS == 64); for output in &spec.expected_outputs { if output.concept.is_empty() || output.concept.len() > MAXIMUM_IDENTIFIER_BYTES @@ -379,11 +391,37 @@ fn validate(spec: &EvidenceRequestSpec) -> Result<(), EvidenceClientError> { "each expected output must name a bounded concept once", )); } + if let ExpectedFormDocument::List(list) = &output.form { + let minimum_items = list.list.minimum_items; + let maximum_items = list.list.maximum_items; + // A specification with a minimum above its maximum can never be + // satisfied, so accepting it would only defer the failure to the + // deployment, where the caller cannot diagnose it. + if !(1..=MAXIMUM_LIST_ITEMS).contains(&minimum_items) + || !(1..=MAXIMUM_LIST_ITEMS).contains(&maximum_items) + || minimum_items > maximum_items + { + return Err(EvidenceClientError::configuration( + "each list-form output must state a cardinality within 1..=64 items, with the minimum no greater than the maximum", + )); + } + } + } + + // Ties the message below to the constant, so the constant cannot drift + // from the number the message states. + const _: () = assert!(MAXIMUM_ASSERTION_LIFETIME_SECONDS == 31_536_000); + if !(1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&spec.maximum_assertion_lifetime_seconds) + { + return Err(EvidenceClientError::configuration( + "the maximum assertion lifetime must be within 1..=31536000 seconds", + )); } - if spec.maximum_assertion_lifetime_seconds == 0 { + const _: () = assert!(MAXIMUM_CLOCK_SKEW_SECONDS == 300); + if spec.clock_skew_seconds > MAXIMUM_CLOCK_SKEW_SECONDS { return Err(EvidenceClientError::configuration( - "the maximum assertion lifetime must be greater than zero", + "the clock skew must be within 0..=300 seconds", )); } @@ -483,7 +521,10 @@ fn bounded_lowercase(value: &str, maximum_bytes: usize, acceptable: impl Fn(u8) #[cfg(test)] mod tests { use super::*; - use registry_evidence_verifier::verifier::{ExpectedFormDocument, ExpectedScalarFormDocument}; + use registry_evidence_verifier::verifier::{ + ExpectedFormDocument, ExpectedListDocument, ExpectedListFormDocument, + ExpectedScalarFormDocument, + }; fn expected_output() -> ExpectedOutputDocument { ExpectedOutputDocument { @@ -492,6 +533,18 @@ mod tests { } } + fn list_expected_output(minimum_items: usize, maximum_items: usize) -> ExpectedOutputDocument { + ExpectedOutputDocument { + concept: "urn:example:client:concept:list-output".to_owned(), + form: ExpectedFormDocument::List(ExpectedListFormDocument { + list: ExpectedListDocument { + minimum_items, + maximum_items, + }, + }), + } + } + fn spec() -> EvidenceRequestSpec { EvidenceRequestSpec { requirement: "urn:example:client:requirement:status:v1".to_owned(), @@ -729,6 +782,34 @@ mod tests { "a lifetime of zero", Box::new(|spec| spec.maximum_assertion_lifetime_seconds = 0), ), + ( + "a lifetime above the contract's ceiling", + Box::new(|spec| { + spec.maximum_assertion_lifetime_seconds = + MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1; + }), + ), + ( + "a clock skew above the contract's ceiling", + Box::new(|spec| { + spec.clock_skew_seconds = MAXIMUM_CLOCK_SKEW_SECONDS + 1; + }), + ), + ( + "a list cardinality of zero", + Box::new(|spec| spec.expected_outputs.push(list_expected_output(0, 1))), + ), + ( + "a list cardinality above the contract's ceiling", + Box::new(|spec| { + spec.expected_outputs + .push(list_expected_output(1, MAXIMUM_LIST_ITEMS + 1)); + }), + ), + ( + "a list minimum above its maximum", + Box::new(|spec| spec.expected_outputs.push(list_expected_output(2, 1))), + ), ( "a pinned role the request does not ask for", Box::new(|spec| { @@ -824,6 +905,42 @@ mod tests { MAXIMUM_SELECTOR_VALUES, 16, "a refusal says \"between one and sixteen of them\"" ); + assert_eq!( + MAXIMUM_ASSERTION_LIFETIME_SECONDS, 31_536_000, + "a refusal says \"1..=31536000 seconds\"" + ); + assert_eq!( + MAXIMUM_CLOCK_SKEW_SECONDS, 300, + "a refusal says \"0..=300 seconds\"" + ); + assert_eq!(MAXIMUM_LIST_ITEMS, 64, "a refusal says \"1..=64 items\""); + + // The ceiling itself, and the floor itself, are still legal: a refusal + // one step past an edge does not mean the edge itself is refused. + let mut at_the_lifetime_ceiling = spec(); + at_the_lifetime_ceiling.maximum_assertion_lifetime_seconds = + MAXIMUM_ASSERTION_LIFETIME_SECONDS; + PreparedEvidenceRequest::new(at_the_lifetime_ceiling) + .expect("the lifetime ceiling itself is accepted"); + + let mut at_the_skew_ceiling = spec(); + at_the_skew_ceiling.clock_skew_seconds = MAXIMUM_CLOCK_SKEW_SECONDS; + PreparedEvidenceRequest::new(at_the_skew_ceiling) + .expect("the clock skew ceiling itself is accepted"); + + let mut at_the_list_floor_and_ceiling = spec(); + at_the_list_floor_and_ceiling + .expected_outputs + .push(list_expected_output(1, MAXIMUM_LIST_ITEMS)); + PreparedEvidenceRequest::new(at_the_list_floor_and_ceiling) + .expect("a list cardinality spanning the floor to the ceiling is accepted"); + + let mut equal_at_the_list_ceiling = spec(); + equal_at_the_list_ceiling + .expected_outputs + .push(list_expected_output(MAXIMUM_LIST_ITEMS, MAXIMUM_LIST_ITEMS)); + PreparedEvidenceRequest::new(equal_at_the_list_ceiling) + .expect("a minimum equal to the maximum is accepted, even at the ceiling"); } /// The contract's integer bounds are the ones a double can represent From 8be40f97595b44c3d0efc3fa822b2e211b2b9b82 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:35:02 +0700 Subject: [PATCH 57/67] feat(evidence): let a caller ask whether the verifier can use a key set The rules a trusted key set must satisfy lived inside the verification path, so the only way to learn a pinned set could never verify anything was to attempt a verification. A relying party configuring a client wants that answer at construction, and restating the rules there would let the two copies drift. `trusted_keys_are_usable` asks the existing rule once, at the point the set is built, and carries no detail beyond usable or not, so a caller cannot surface key material by rendering the refusal. Signed-off-by: Jeremi Joslin --- .../src/verifier.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/registry-evidence-verifier/src/verifier.rs b/crates/registry-evidence-verifier/src/verifier.rs index 84d6e74c2..b1873774e 100644 --- a/crates/registry-evidence-verifier/src/verifier.rs +++ b/crates/registry-evidence-verifier/src/verifier.rs @@ -402,6 +402,17 @@ struct SdJwtHeader { typ: String, } +/// Whether a pinned trusted key set is one this verifier could ever use. +/// +/// Verification applies this rule to every response, so a key set that fails it +/// can never verify anything. Exposing it lets a relying party apply it once, +/// where it pinned the set, instead of learning per response that the pinning +/// decision was unusable. The rule stays owned here: this is the same check +/// verification performs, not a restatement of it. +pub fn trusted_keys_are_usable(trusted_jwks: &JwksDocument) -> Result<(), VerificationError> { + trusted_keys(trusted_jwks).map(|_| ()) +} + /// Strict one-call verification: cryptographic authenticity, every policy /// expectation, and current validity must all hold. pub fn verify_flattened_jws( @@ -1714,6 +1725,56 @@ mod tests { ); } + /// A relying party pins its trusted key set once, long before any response + /// arrives. This check is what lets it learn there that the set is unusable, + /// so it must refuse exactly what verification refuses. + #[tokio::test] + async fn the_pinned_key_set_check_agrees_with_what_verification_would_refuse() { + let (jws, usable, policy) = signed_fixture().await; + assert!(trusted_keys_are_usable(&usable).is_ok()); + assert!(verify_flattened_jws(&jws, &usable, &policy).is_ok()); + + let one_key = usable.keys[0].clone(); + let mut private_material = one_key.clone(); + private_material["d"] = serde_json::json!("cHJpdmF0ZS1zY2FsYXItcGxhY2Vob2xkZXI"); + let mut absent_kid = one_key.clone(); + absent_kid + .as_object_mut() + .expect("the key is an object") + .remove("kid"); + let mut empty_kid = one_key.clone(); + empty_kid["kid"] = serde_json::json!(""); + for keys in [ + // Nothing to verify against. + vec![], + // Private material a public set must never carry. + vec![private_material], + // No identifier to select the key by. + vec![absent_kid], + vec![empty_kid], + // Two keys claiming one identifier. + vec![one_key.clone(), one_key.clone()], + // Not the signature algorithm the profile fixes. + vec![ + serde_json::json!({"kty": "EC", "crv": "P-256", "kid": "es256", "alg": "ES256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}), + ], + ] { + let refused = JwksDocument { keys }; + assert_eq!( + trusted_keys_are_usable(&refused), + Err(VerificationError::Key), + "the pinning check accepted a set verification refuses" + ); + assert_eq!( + verify_flattened_jws(&jws, &refused, &policy), + Err(VerificationError::Key), + "verification and the pinning check disagree" + ); + } + } + /// Issue the fixture as an SD-JWT VC through the same signer, mapping, and /// key set the runtime uses. async fn issued_sd_jwt_vc() -> (String, JwksDocument, EvidenceVerificationPolicy) { From 177f745c215ef16d21b587c85078bb6f14566da3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:37:29 +0700 Subject: [PATCH 58/67] fix(evidence): bound the Python binding's value conversion Two ways a caller's own value could reach a place the bridge did not survive. `python_to_json` followed an arbitrary Python object graph with no depth limit. Unlike a JSON document, nothing bounded that graph on the way in: it may nest far past anything a parser would have accepted, and it may hold itself. A mapping that holds itself, which is a single readable line of Python, descended until the process died. One finite depth bound refuses both, since a cycle is exactly a graph that descends without end; it mirrors `serde_json`'s own recursion limit. `duration_from_seconds` refused a negative, infinite, or `NaN` input and then called `Duration::from_secs_f64`, which still panics on a finite value larger than the seconds a `Duration` holds. `Duration::try_from_secs_f64` reports all four as errors, so the bridge now asks it once instead of screening for a subset. Both tests pin the accepted edge as well as the refusal, so a later tightening cannot pass by refusing everything. Signed-off-by: Jeremi Joslin --- .../src/convert.rs | 101 ++++++++++++++++-- .../tests/python/test_construction.py | 11 ++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index 559f2f132..0c7ac9670 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -137,15 +137,16 @@ fn parse_url(value: &str, what: &str) -> Result { /// Turn a caller-supplied number of seconds into a [`Duration`]. /// /// Python's idiom for a timeout is a float number of seconds, unlike the -/// Node binding's millisecond integers. `Duration::from_secs_f64` panics on a -/// negative, infinite, or `NaN` input, so those are refused here first. +/// Node binding's millisecond integers. A negative, infinite, or `NaN` input, +/// and a finite value larger than the `u64::MAX` seconds a [`Duration`] holds, +/// are all refused: `Duration::try_from_secs_f64` reports every one of them as +/// an error, where `Duration::from_secs_f64` would panic. fn duration_from_seconds(seconds: f64, what: &str) -> Result { - if !seconds.is_finite() || seconds < 0.0 { - return Err(ConversionError::new(format!( - "{what} must be a finite, non-negative number of seconds" - ))); - } - Ok(Duration::from_secs_f64(seconds)) + Duration::try_from_secs_f64(seconds).map_err(|_| { + ConversionError::new(format!( + "{what} must be a finite, non-negative number of seconds that a duration can hold" + )) + }) } /// Turn a caller-supplied UNIX timestamp (seconds, as `datetime.timestamp()` @@ -163,12 +164,41 @@ pub fn datetime_from_unix_seconds(seconds: f64) -> Result, Convers .ok_or_else(|| ConversionError::new("a timestamp is outside the representable range")) } +/// How deep [`python_to_json`] will descend, mirroring `serde_json`'s own +/// deserialization recursion limit. +/// +/// A Python value is an arbitrary object graph, not a document `serde_json` +/// already bounded on the way in: it may nest far deeper than any JSON text a +/// caller could have parsed, and it may be cyclic. One finite bound refuses +/// both, since a cycle simply descends until it reaches the bound. +const MAX_JSON_DEPTH: usize = 128; + /// Convert a Python value to a [`serde_json::Value`]. /// /// The Python `bool` type is a subtype of `int`, so a boolean value must be /// recognized before an integer downcast is attempted; checking in the /// opposite order would silently turn `True`/`False` into `1`/`0`. pub fn python_to_json(value: &Bound<'_, PyAny>) -> Result { + python_to_json_at_depth(value, 1) +} + +/// Convert one value at a known nesting level, where the top-level value is +/// level 1 and each container's items sit one level below it. +/// +/// The bound is checked before the value is inspected at all, so a graph that +/// descends past [`MAX_JSON_DEPTH`] is refused rather than followed. Nothing +/// tracks which containers have already been seen: a cycle is exactly a graph +/// that descends without end, and the bound stops it at the same level it +/// stops any other. +fn python_to_json_at_depth( + value: &Bound<'_, PyAny>, + depth: usize, +) -> Result { + if depth > MAX_JSON_DEPTH { + return Err(ConversionError::new(format!( + "a value nested more than {MAX_JSON_DEPTH} levels deep cannot be converted" + ))); + } if value.is_none() { return Ok(Value::Null); } @@ -198,14 +228,14 @@ pub fn python_to_json(value: &Bound<'_, PyAny>) -> Result() { let items = list .iter() - .map(|item| python_to_json(&item)) + .map(|item| python_to_json_at_depth(&item, depth + 1)) .collect::, _>>()?; return Ok(Value::Array(items)); } if let Ok(tuple) = value.cast::() { let items = tuple .iter() - .map(|item| python_to_json(&item)) + .map(|item| python_to_json_at_depth(&item, depth + 1)) .collect::, _>>()?; return Ok(Value::Array(items)); } @@ -218,7 +248,7 @@ pub fn python_to_json(value: &Bound<'_, PyAny>) -> Result, depth: usize) -> Bound<'_, PyAny> { + let mut value = PyList::empty(py).into_any(); + for _ in 1..depth { + value = PyList::new(py, [value]) + .expect("a list holding one item is built") + .into_any(); + } + value + } + + Python::attach(|py| { + assert!(python_to_json(&nested_lists(py, MAX_JSON_DEPTH)).is_ok()); + assert!(python_to_json(&nested_lists(py, MAX_JSON_DEPTH + 1)).is_err()); + }); + } + + /// A Python mapping may hold itself, which no JSON document can express. + /// The depth bound is what refuses it: the descent reaches the limit and + /// stops, so the same check covers a cycle and a merely deep graph. + #[test] + fn python_to_json_refuses_a_cyclic_mapping() { + Python::attach(|py| { + let dict = PyDict::new(py); + dict.set_item("self", &dict) + .expect("a mapping holds itself"); + assert!(python_to_json(&dict.into_any()).is_err()); + }); + } + #[test] fn json_to_python_round_trips_through_python_to_json() { Python::attach(|py| { @@ -1106,6 +1171,20 @@ mod tests { ); } + /// A `Duration` holds at most `u64::MAX` whole seconds, so a perfectly + /// finite, positive `f64` can still be too large for one. The accepted + /// value pins that bound rather than only proving it was tightened. + #[test] + fn duration_from_seconds_refuses_a_value_too_large_for_a_duration() { + for seconds in [1e300, 2e19, f64::MAX] { + assert!( + duration_from_seconds(seconds, "`x`").is_err(), + "{seconds} should have been refused" + ); + } + assert!(duration_from_seconds(1e19, "`x`").is_ok()); + } + #[test] fn datetime_from_unix_seconds_refuses_non_finite_input() { for seconds in [f64::NEG_INFINITY, f64::INFINITY, f64::NAN] { diff --git a/crates/registry-evidence-client-py/tests/python/test_construction.py b/crates/registry-evidence-client-py/tests/python/test_construction.py index 7b0481fa1..020fa11bd 100644 --- a/crates/registry-evidence-client-py/tests/python/test_construction.py +++ b/crates/registry-evidence-client-py/tests/python/test_construction.py @@ -45,6 +45,17 @@ def test_a_base_url_with_an_empty_path_segment_is_refused(self): ) self.assertEqual(raised.exception.kind, "configuration") + def test_a_cyclic_mapping_is_refused_as_a_configuration_error(self): + # Refused earlier than the cases above, in the Python-to-JSON bridge + # (`src/convert.rs`) rather than in `validate`: a mapping that holds + # itself has no depth to convert, so the bridge's depth bound ends the + # descent and the interpreter stays alive to raise. + cyclic = {} + cyclic["self"] = cyclic + with self.assertRaises(revc.ConfigurationError) as raised: + revc.EvidenceClient("https://example.org", cyclic, "test-token") + self.assertEqual(raised.exception.kind, "configuration") + def test_a_loopback_http_base_url_is_accepted(self): # Not a refusal case: confirms the three refusals above are testing # the specific rules, not "any base URL fails". Port 1 is never From 4f61fac74f9ac9c98a59c94a396725eb0df84d70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:38:54 +0700 Subject: [PATCH 59/67] fix(evidence): judge the client's configuration where the decision is made Two fixes in `EvidenceClientConfig::validate`, which share the function and so share a commit. The pinned key set was checked only for emptiness, so a set the verifier could never use (a private key, a missing or duplicate `kid`, an algorithm other than EdDSA) was accepted at construction and failed once per request, where it reads to an adopter as a deployment fault rather than their own configuration. `validate` now asks the verifier's own rule instead of restating it, so the two cannot drift, and discards its detail so no refusal can render key material. The single response bound also governed the discovery document and the published key set. Its default is derived from what the verifier will accept as a signed response, and neither of those documents is signed or verified, so that reasoning never reached them: a relying party tightening the bound to what its own assertions need would silently lose the ability to read discovery. A separate metadata bound now covers the two unsigned documents, with the same 256 KiB default justified on its own terms (roughly five hundred definitions, past what a deployment publishes, and raisable). Both bindings expose it, since both already exposed the response bound. Also drops `config.rs`'s copy of the userinfo stripping helper, which the token endpoint redaction fix moved into `outbound` for both callers. Review note: data minimization (the key set refusal carries no key detail) and a bound on unauthenticated remote input. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client-node/README.md | 8 +- .../__test__/discovery.test.js | 34 ++- .../registry-evidence-client-node/index.d.ts | 9 +- .../src/convert.rs | 3 + .../registry-evidence-client-node/src/lib.rs | 9 +- crates/registry-evidence-client-py/README.md | 9 +- .../registry_evidence_client/__init__.pyi | 1 + .../src/convert.rs | 11 + crates/registry-evidence-client-py/src/lib.rs | 15 +- .../tests/python/test_discovery.py | 35 ++- crates/registry-evidence-client/src/client.rs | 256 ++++++++++++++++-- crates/registry-evidence-client/src/config.rs | 137 ++++++++-- 12 files changed, 459 insertions(+), 68 deletions(-) diff --git a/crates/registry-evidence-client-node/README.md b/crates/registry-evidence-client-node/README.md index fe06f6b13..f0f4ed97d 100644 --- a/crates/registry-evidence-client-node/README.md +++ b/crates/registry-evidence-client-node/README.md @@ -53,11 +53,17 @@ checks `status` without also checking `kind` can misclassify a 429 rate limit as a generic protocol failure, or vice versa. See `registry-evidence-client`'s `problem.rs` for the authoritative mapping table. -A response that exceeds `maxResponseBytes` maps to `kind: "transport"` with +A response that exceeds its size bound maps to `kind: "transport"` with `transportKind: "response_too_large"`, not `kind: "protocol"`, even when the response status itself was a plain 200: the size limit is enforced against the transport, before any attempt to interpret the body as a problem response. +Which bound applies depends on the call. `maxResponseBytes` bounds the signed +response body that `send()` and `requestAndVerify()` read, and its default +follows what the verifier will accept as a signed response. `maxMetadataBytes` +bounds the documents `discover()` and `fetchJwks()` read, neither of which is +signed or verified. Tightening one does not tighten the other. + ### Nonce and the golden fixture `prepare()` generates a fresh request nonce on every call; there is no seam diff --git a/crates/registry-evidence-client-node/__test__/discovery.test.js b/crates/registry-evidence-client-node/__test__/discovery.test.js index e1d832095..febdb88a7 100644 --- a/crates/registry-evidence-client-node/__test__/discovery.test.js +++ b/crates/registry-evidence-client-node/__test__/discovery.test.js @@ -44,21 +44,26 @@ const DEFINITIONS_DOCUMENT = { ], }; -function clientAgainst(stub) { +function clientAgainst(stub, bounds = {}) { return new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: GOLDEN_JWKS, token: { static: 'discovery-test-token' }, + ...bounds, }); } -test('discover reads a valid definitions document from a stub deployment', async () => { - const stub = await startStubServer({ +function definitionsStub() { + return startStubServer({ 'GET /v1/evidence-definitions': (req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(DEFINITIONS_DOCUMENT)); }, }); +} + +test('discover reads a valid definitions document from a stub deployment', async () => { + const stub = await definitionsStub(); try { const client = clientAgainst(stub); const document = await client.discover(); @@ -71,6 +76,29 @@ test('discover reads a valid definitions document from a stub deployment', async } }); +// The two bounds answer different questions: `maxResponseBytes` is derived from +// what the verifier accepts as a signed response, and the discovery document is +// neither signed nor verified. Tightening one must not silently disable the +// other endpoint. +test('the metadata bound governs discovery, and the signed response bound does not', async () => { + const stub = await definitionsStub(); + try { + const document = await clientAgainst(stub, { maxResponseBytes: 1 }).discover(); + assert.equal(document.definitions.length, 1); + + const bounded = clientAgainst(stub, { + maxMetadataBytes: JSON.stringify(DEFINITIONS_DOCUMENT).length - 1, + }); + await assert.rejects(bounded.discover(), (error) => { + assert.equal(error.kind, 'transport'); + assert.equal(error.transportKind, 'response_too_large'); + return true; + }); + } finally { + await stub.close(); + } +}); + test('fetchJwks reads the deployment key set from a stub deployment', async () => { const stub = await startStubServer({ 'GET /.well-known/evidence/jwks.json': (req, res) => { diff --git a/crates/registry-evidence-client-node/index.d.ts b/crates/registry-evidence-client-node/index.d.ts index 6975d9cd6..f20c13095 100644 --- a/crates/registry-evidence-client-node/index.d.ts +++ b/crates/registry-evidence-client-node/index.d.ts @@ -3,8 +3,13 @@ /** A relying party's connection to one Evidence deployment. */ export declare class EvidenceClient { /** - * Build a client for one deployment. `trustedJwks` is mandatory; an empty - * key set is refused, exactly as the Rust configuration is. + * Build a client for one deployment. `trustedJwks` is mandatory; a key set + * the verifier could never use is refused, exactly as the Rust + * configuration refuses it. + * + * `maxResponseBytes` bounds the signed response `send` reads. + * `maxMetadataBytes` bounds the documents `discover` and `fetchJwks` read, + * which are neither signed nor verified, and is a separate decision. */ constructor(config: any) /** diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index d757a8ae6..1b6f33388 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -440,6 +440,9 @@ pub fn config_from_json(value: &Value) -> Result Result { catch_panic("constructing the client", || { diff --git a/crates/registry-evidence-client-py/README.md b/crates/registry-evidence-client-py/README.md index 1c39161d1..9a99480bc 100644 --- a/crates/registry-evidence-client-py/README.md +++ b/crates/registry-evidence-client-py/README.md @@ -58,11 +58,18 @@ checks `status` without also checking `kind` can misclassify a 429 rate limit as a generic protocol failure, or vice versa. See `registry-evidence-client`'s `problem.rs` for the authoritative mapping table. -A response that exceeds `max_response_bytes` maps to `kind: "transport"` with +A response that exceeds its size bound maps to `kind: "transport"` with `transport_kind: "response_too_large"`, not `kind: "protocol"`, even when the response status itself was a plain 200: the size limit is enforced against the transport, before any attempt to interpret the body as a problem response. +Which bound applies depends on the call. `max_response_bytes` bounds the signed +response body that `send()` and `request_and_verify()` read, and its default +follows what the verifier will accept as a signed response. +`max_metadata_bytes` bounds the documents `discover()` and `fetch_jwks()` read, +neither of which is signed or verified. Tightening one does not tighten the +other. + No string reaching Python carries unbounded remote text; no exception carries response bytes, a credential, a header value, a selector value, or a subject binding. diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi index cc00b6763..388e255a5 100644 --- a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi @@ -132,6 +132,7 @@ class EvidenceClient: user_agent: Optional[str] = ..., trusted_root_certificates: Optional[bytes] = ..., max_response_bytes: Optional[int] = ..., + max_metadata_bytes: Optional[int] = ..., ) -> None: ... def prepare(self, spec: Any) -> PreparedEvidenceRequest: ... def discover(self) -> Any: ... diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index 0c7ac9670..6cc01bd3e 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -591,6 +591,7 @@ pub fn config_from_parts( user_agent: Option, trusted_root_certificates: Option>, max_response_bytes: Option, + max_metadata_bytes: Option, ) -> Result { let base_url = parse_url(base_url, "`base_url`").map_err(ConfigError::Shape)?; @@ -624,6 +625,9 @@ pub fn config_from_parts( if let Some(max_bytes) = max_response_bytes { config = config.with_max_response_bytes(max_bytes); } + if let Some(max_bytes) = max_metadata_bytes { + config = config.with_max_metadata_bytes(max_bytes); + } Ok(config) } @@ -1078,6 +1082,7 @@ mod tests { None, None, None, + None, ) .expect("the configuration is well-shaped"); assert_eq!(config.base_url().as_str(), "https://evidence.example/"); @@ -1101,6 +1106,7 @@ mod tests { Some("test-agent".to_owned()), None, Some(1024), + Some(2048), ) .expect("the configuration is well-shaped"); } @@ -1116,6 +1122,7 @@ mod tests { None, None, None, + None, ) .unwrap_err(); assert!(matches!(error, ConfigError::Shape(_))); @@ -1132,6 +1139,7 @@ mod tests { None, None, None, + None, ) .unwrap_err(); assert!(matches!(error, ConfigError::Shape(_))); @@ -1155,6 +1163,7 @@ mod tests { None, None, None, + None, ) .unwrap_err(); assert!(matches!(error, ConfigError::Shape(_))); @@ -1404,6 +1413,7 @@ mod tests { None, None, None, + None, ) .expect_err("a newline is refused"); assert!(matches!( @@ -1438,6 +1448,7 @@ mod tests { None, None, None, + None, ) .expect_err("the malformed key is refused"); assert!(matches!(error, ConfigError::Shape(_))); diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs index ecdc15435..f03de6994 100644 --- a/crates/registry-evidence-client-py/src/lib.rs +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -287,10 +287,14 @@ struct EvidenceClient { impl EvidenceClient { /// Build a client for one deployment. /// - /// `trusted_jwks` is mandatory: an empty key set is refused, exactly as - /// the wrapped Rust configuration refuses it. `token` is either a static - /// bearer string or the private-key-JWT provider's own settings; there is - /// no caller-supplied token provider in this binding. + /// `trusted_jwks` is mandatory: a key set the verifier could never use is + /// refused, exactly as the wrapped Rust configuration refuses it. `token` + /// is either a static bearer string or the private-key-JWT provider's own + /// settings; there is no caller-supplied token provider in this binding. + /// + /// `max_response_bytes` bounds the signed response `send` reads. + /// `max_metadata_bytes` bounds the documents `discover` and `fetch_jwks` + /// read, which are neither signed nor verified, and is a separate decision. #[new] #[pyo3(signature = ( base_url, @@ -301,6 +305,7 @@ impl EvidenceClient { user_agent=None, trusted_root_certificates=None, max_response_bytes=None, + max_metadata_bytes=None, ))] #[allow(clippy::too_many_arguments)] fn new( @@ -313,6 +318,7 @@ impl EvidenceClient { user_agent: Option, trusted_root_certificates: Option>, max_response_bytes: Option, + max_metadata_bytes: Option, ) -> PyResult { let trusted_jwks_json = python_to_json(trusted_jwks) .map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; @@ -327,6 +333,7 @@ impl EvidenceClient { user_agent, trusted_root_certificates, max_response_bytes, + max_metadata_bytes, ) .map_err(|error| to_py_err(py, &map_config_error(&error)))?; let inner = py diff --git a/crates/registry-evidence-client-py/tests/python/test_discovery.py b/crates/registry-evidence-client-py/tests/python/test_discovery.py index 0534479ec..e2cc287b9 100644 --- a/crates/registry-evidence-client-py/tests/python/test_discovery.py +++ b/crates/registry-evidence-client-py/tests/python/test_discovery.py @@ -45,26 +45,43 @@ def setUp(self) -> None: self.server = StubServer({}) self.addCleanup(self.server.close) - def _client(self): + def _client(self, **bounds): return revc.EvidenceClient( - self.server.base_url, fixtures.VALID_JWKS, "test-token" + self.server.base_url, fixtures.VALID_JWKS, "test-token", **bounds ) - def test_discover_returns_the_definitions_document_as_a_dict(self): + def _serve_definitions(self) -> bytes: + body = json.dumps(DEFINITIONS_DOCUMENT).encode("utf-8") self.server.routes["GET /v1/evidence-definitions"] = StubRoute( status=200, headers={"Content-Type": JSON_MEDIA_TYPE}, - body=json.dumps(DEFINITIONS_DOCUMENT).encode("utf-8"), + body=body, ) + return body + + def test_discover_returns_the_definitions_document_as_a_dict(self): + self._serve_definitions() document = self._client().discover() self.assertEqual(document, DEFINITIONS_DOCUMENT) + def test_the_metadata_bound_governs_discovery_and_the_response_bound_does_not(self): + """The two bounds answer different questions. + + `max_response_bytes` is derived from what the verifier accepts as a + signed response, and the discovery document is neither signed nor + verified, so tightening one must not silently disable the other + endpoint. + """ + body = self._serve_definitions() + document = self._client(max_response_bytes=1).discover() + self.assertEqual(document, DEFINITIONS_DOCUMENT) + + with self.assertRaises(revc.TransportError) as raised: + self._client(max_metadata_bytes=len(body) - 1).discover() + self.assertEqual(raised.exception.transport_kind, "response_too_large") + def test_discover_sends_a_bearer_credential(self): - self.server.routes["GET /v1/evidence-definitions"] = StubRoute( - status=200, - headers={"Content-Type": JSON_MEDIA_TYPE}, - body=json.dumps(DEFINITIONS_DOCUMENT).encode("utf-8"), - ) + self._serve_definitions() self._client().discover() self.assertEqual(len(self.server.requests), 1) self.assertEqual( diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index e066d24af..6be89bbd2 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -21,7 +21,7 @@ use zeroize::Zeroizing; use crate::{ config::EvidenceClientConfig, - definitions::EvidenceDefinitionsDocument, + definitions::{EvidenceDefinitionsDocument, EVIDENCE_DEFINITIONS_SCHEMA_V1}, error::EvidenceClientError, outbound::{self, OutboundOptions}, prepare::{EvidenceRequestSpec, PreparedEvidenceRequest, SubjectExpectations}, @@ -42,6 +42,16 @@ const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; /// The opaque per-request identifier the deployment returns. const CORRELATION_HEADER: &str = "x-request-id"; +/// Longest `Retry-After` wait this client reports as actionable. +/// +/// The problem contract permits a wait only for bounded transient failures, and +/// states no value, so the bound is the client's own. `Retry-After` is a +/// response-controlled field: a caller that honors an unbounded one would stop +/// for as long as any hop on the path chose, and a wait of zero would invite an +/// immediate retry loop. A longer wait is not reported at all, which leaves the +/// caller its own backoff rather than an instruction it did not ask for. +pub const MAXIMUM_RETRY_AFTER_SECONDS: u64 = 60; + /// Whether an exchange carries the relying party's bearer credential. /// /// Named rather than a boolean, so a call site states which of the two it means @@ -165,8 +175,22 @@ impl EvidenceClient { /// party what it may ask for; it never supplies verification expectations /// for a request already in flight. pub async fn discover(&self) -> Result { - self.get_json(DEFINITIONS_PATH, JSON_MEDIA_TYPE, Credential::Required) - .await + let (document, operation): (EvidenceDefinitionsDocument, _) = self + .get_json(DEFINITIONS_PATH, JSON_MEDIA_TYPE, Credential::Required) + .await?; + // These types would accept a later document that happened to fit them, and + // the relying party would then author requests for a shape whose meaning + // it guessed. The rest of the definitions contract is the deployment's to + // apply; only the version this client reads is checked here. + if document.schema != EVIDENCE_DEFINITIONS_SCHEMA_V1 { + return Err(EvidenceClientError::Protocol { + status: StatusCode::OK.as_u16(), + code: None, + operation, + retry_after_seconds: None, + }); + } + Ok(document) } /// Read the deployment's published verification key set. @@ -180,8 +204,10 @@ impl EvidenceClient { // The published key set is public, and it is not a trust anchor here, so // there is nothing to gain by presenting the relying party's credential // to fetch it. - self.get_json(JWKS_PATH, JWKS_MEDIA_TYPE, Credential::None) - .await + let (document, _operation) = self + .get_json(JWKS_PATH, JWKS_MEDIA_TYPE, Credential::None) + .await?; + Ok(document) } /// Send one prepared request and read the signed response. @@ -211,7 +237,12 @@ impl EvidenceClient { .header(CONTENT_TYPE, JSON_MEDIA_TYPE) .body(body); let response = self.exchange(request, Credential::Required).await?; - self.expect_success(response, EVIDENCE_JWS_MEDIA_TYPE).await + self.expect_success( + response, + EVIDENCE_JWS_MEDIA_TYPE, + self.config.max_response_bytes, + ) + .await } /// Verify a signed response against the policy its request closed. @@ -306,25 +337,32 @@ impl EvidenceClient { /// both authoring input rather than verification input, and a body that does /// not parse is a protocol failure rather than a refusal: the deployment /// answered, and the answer was not the document it promised. + /// The deployment's own identifier for the exchange is returned beside the + /// document, so a caller that refuses the parsed document still has the one + /// value the problem contract calls safe for support correlation. async fn get_json( &self, path: &str, media_type: &str, credential: Credential, - ) -> Result { + ) -> Result<(T, Option), EvidenceClientError> { let url = self.endpoint(path)?; let request = self .http .request(Method::GET, url) .header(ACCEPT, media_type); let response = self.exchange(request, credential).await?; - let body = self.expect_success(response, media_type).await?; - serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { - status: StatusCode::OK.as_u16(), - code: None, - operation: body.operation, - retry_after_seconds: None, - }) + let body = self + .expect_success(response, media_type, self.config.max_metadata_bytes) + .await?; + let document = + serde_json::from_slice(&body.body).map_err(|_| EvidenceClientError::Protocol { + status: StatusCode::OK.as_u16(), + code: None, + operation: body.operation.clone(), + retry_after_seconds: None, + })?; + Ok((document, body.operation)) } /// Resolve one endpoint under the configured base URL. @@ -380,10 +418,15 @@ impl EvidenceClient { /// Read a successful response of exactly one media type, or map the /// deployment's answer onto a client failure. + /// + /// `max_bytes` is the caller's, because the signed response and the + /// deployment's metadata documents are bounded by separate configuration + /// decisions. async fn expect_success( &self, response: reqwest::Response, expected_media_type: &str, + max_bytes: u64, ) -> Result { let status = response.status().as_u16(); let operation = response @@ -400,9 +443,10 @@ impl EvidenceClient { .headers() .get(RETRY_AFTER) .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()); + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| (1..=MAXIMUM_RETRY_AFTER_SECONDS).contains(seconds)); - let body = match read_bounded(response, self.config.max_response_bytes).await { + let body = match read_bounded(response, max_bytes).await { Ok(body) => body, // The status and the correlation identifier arrived before the body // did. A refusal keeps them, because they are the whole support @@ -1040,6 +1084,186 @@ mod tests { ); } + /// One discovery document, minimal but complete: the schema discriminator is + /// what these two tests vary, and an empty entitlement list is a shape the + /// definitions contract permits. + fn definitions_json(schema: &str) -> String { + format!( + r#"{{"schema":"{schema}","assuranceProfile":"local","configurationRevision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","issuedBy":"urn:example:client:issuer","providedBy":"urn:example:client:provider","definitions":[]}}"# + ) + } + + async fn discovery_client( + server: &MockServer, + fixture: &SignedEvidenceFixture, + body: String, + ) -> EvidenceClient { + discovery_client_with(server, fixture, body, |config| config).await + } + + async fn discovery_client_with( + server: &MockServer, + fixture: &SignedEvidenceFixture, + body: String, + bounds: impl FnOnce(EvidenceClientConfig) -> EvidenceClientConfig, + ) -> EvidenceClient { + Mock::given(method("GET")) + .and(path("/v1/evidence-definitions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header(CORRELATION_HEADER, OPERATION) + .set_body_raw(body.into_bytes(), JSON_MEDIA_TYPE), + ) + .mount(server) + .await; + EvidenceClient::new(bounds(config_for(&server.uri(), fixture))) + .expect("the client is configured") + } + + /// The signed response bound is derived from what the verifier will accept as + /// a signed response, and discovery is neither signed nor verified. A relying + /// party that tightens the response bound to what its own assertions need must + /// keep being able to read discovery, and one that raises the discovery bound + /// for a deployment publishing many definitions must not thereby accept a + /// larger signed body than it decided to. + #[tokio::test] + async fn the_metadata_bound_is_not_the_signed_response_bound() { + let fixture = signed_evidence(); + let document = definitions_json(EVIDENCE_DEFINITIONS_SCHEMA_V1); + let document_bytes = document.len() as u64; + + let server = MockServer::start().await; + let client = discovery_client_with(&server, &fixture, document.clone(), |config| { + config.with_max_response_bytes(1) + }) + .await; + let read = client + .discover() + .await + .expect("the signed response bound does not reach discovery"); + assert_eq!(read.schema, EVIDENCE_DEFINITIONS_SCHEMA_V1); + + let server = MockServer::start().await; + let client = discovery_client_with(&server, &fixture, document, |config| { + config.with_max_metadata_bytes(document_bytes - 1) + }) + .await; + assert_eq!( + client + .discover() + .await + .expect_err("a document past the metadata bound is not read"), + EvidenceClientError::transport(TransportKind::ResponseTooLarge) + ); + } + + /// Discovery is authoring input, so a document announcing a schema version + /// this client does not understand must not become authoring input anyway. + /// These Rust types would accept a later document that happens to fit them, + /// and the relying party would then author requests for a shape whose meaning + /// it guessed. + #[tokio::test] + async fn a_discovery_document_announcing_another_schema_is_a_protocol_failure() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = discovery_client( + &server, + &fixture, + definitions_json("registry.evidence-definitions/v2"), + ) + .await; + + assert_eq!( + client + .discover() + .await + .expect_err("the announced schema is not the one this client reads"), + EvidenceClientError::Protocol { + status: 200, + code: None, + // The deployment's own identifier for the exchange survives, so an + // adopter has something to quote when the versions disagree. + operation: Some(OPERATION.to_owned()), + retry_after_seconds: None, + } + ); + } + + #[tokio::test] + async fn a_discovery_document_announcing_the_read_schema_is_accepted() { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = discovery_client( + &server, + &fixture, + definitions_json(EVIDENCE_DEFINITIONS_SCHEMA_V1), + ) + .await; + + let document = client + .discover() + .await + .expect("the document is the v1 shape"); + assert_eq!(document.schema, EVIDENCE_DEFINITIONS_SCHEMA_V1); + assert!(document.definitions.is_empty()); + } + + /// `Retry-After` is a response-controlled value, and the client documents the + /// wait it reports as actionable. A hostile or misconfigured hop answering with + /// a day would have a caller that honors it stop for a day, and a zero would + /// invite an immediate retry loop, so only a wait a relying party would + /// plausibly honor is reported at all. + #[tokio::test] + async fn a_wait_the_transient_contract_does_not_bound_is_not_reported_as_actionable() { + let bound = MAXIMUM_RETRY_AFTER_SECONDS.to_string(); + for (header, expected_wait) in [ + ("1", Some(1)), + (bound.as_str(), Some(MAXIMUM_RETRY_AFTER_SECONDS)), + ("0", None), + ("86400", None), + // The field grammar also permits an HTTP date, which this client has + // never read and must not read as a count of seconds. + ("Fri, 31 Dec 1999 23:59:59 GMT", None), + ] { + let fixture = signed_evidence(); + let server = MockServer::start().await; + let client = client_for(&server.uri(), &fixture); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the specification is accepted"); + Mock::given(method("POST")) + .and(path("/v1/evidence")) + .respond_with( + ResponseTemplate::new(429) + .insert_header(CORRELATION_HEADER, OPERATION) + .insert_header(RETRY_AFTER.as_str(), header) + .set_body_raw( + format!( + r#"{{"type":"https://registrystack.org/problems/evidence/rate_limited","title":"Request rate exceeded","status":429,"code":"rate_limited","operation":"{OPERATION}"}}"# + ) + .into_bytes(), + "application/problem+json", + ), + ) + .mount(&server) + .await; + + assert_eq!( + client + .send(&prepared) + .await + .expect_err("the deployment refused the request"), + EvidenceClientError::Denied { + status: 429, + code: "rate_limited".to_owned(), + operation: Some(OPERATION.to_owned()), + retry_after_seconds: expected_wait, + }, + "the wait reported for a `Retry-After` of {header}" + ); + } + } + /// A gateway can answer a refusal with a body far larger than the contract's, /// and the read then fails. The status and the deployment's identifier were /// already in hand, so the failure still carries the support workflow this diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index 358774c2e..e2044573e 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -4,24 +4,41 @@ //! integrator, out of band. The client never replaces it with keys a response //! or a discovery document named. -use std::{borrow::Cow, fmt, sync::Arc, time::Duration}; +use std::{fmt, sync::Arc, time::Duration}; -use registry_evidence_verifier::model::JwksDocument; +use registry_evidence_verifier::{model::JwksDocument, verifier::trusted_keys_are_usable}; use registry_platform_httputil::DEFAULT_OUTBOUND_CONNECT_TIMEOUT; use url::Url; use zeroize::Zeroizing; use crate::{ - error::EvidenceClientError, outbound::transport_protects_the_credential, token::TokenProvider, + error::EvidenceClientError, + outbound::{base_url_without_userinfo, transport_protects_the_credential}, + token::TokenProvider, }; -/// Longest response body the client will read. +/// Longest signed response body the client will read. /// /// The verifier refuses a signed response larger than 256 KiB, so a bigger /// body could never verify and reading it would only waste the relying party's /// memory. pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 256 * 1024; +/// Longest deployment metadata document the client will read: the discovery +/// document, and the published key set. +/// +/// This is a separate decision from [`DEFAULT_MAX_RESPONSE_BYTES`], which is +/// derived from what the verifier will accept as a signed response. Neither +/// document is signed and neither is verified, so that reasoning does not reach +/// them, and a relying party that tightens one bound to what its own assertions +/// need must not thereby stop being able to read discovery. The definitions +/// contract permits far more than this: 16,384 authorized shapes, which even at +/// the smallest conforming entry is several megabytes. This carries roughly five +/// hundred definitions, which is past what a deployment publishes, and +/// [`EvidenceClientConfig::with_max_metadata_bytes`] raises it for one that +/// publishes more. +pub const DEFAULT_MAX_METADATA_BYTES: u64 = 256 * 1024; + /// Total time allowed for one exchange, including reading the response body. pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); @@ -38,6 +55,7 @@ pub struct EvidenceClientConfig { pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>>, pub(crate) max_response_bytes: u64, + pub(crate) max_metadata_bytes: u64, } impl EvidenceClientConfig { @@ -62,6 +80,7 @@ impl EvidenceClientConfig { user_agent: None, trusted_root_certificates: None, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_metadata_bytes: DEFAULT_MAX_METADATA_BYTES, } } @@ -91,12 +110,23 @@ impl EvidenceClientConfig { self } + /// Bound the signed response body, which only [`crate::EvidenceClient::send`] + /// reads. #[must_use] pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { self.max_response_bytes = max_response_bytes; self } + /// Bound the discovery document and the published key set, which + /// [`crate::EvidenceClient::discover`] and + /// [`crate::EvidenceClient::fetch_jwks`] read. + #[must_use] + pub fn with_max_metadata_bytes(mut self, max_metadata_bytes: u64) -> Self { + self.max_metadata_bytes = max_metadata_bytes; + self + } + #[must_use] pub fn base_url(&self) -> &Url { &self.base_url @@ -113,6 +143,11 @@ impl EvidenceClientConfig { self.max_response_bytes } + #[must_use] + pub fn max_metadata_bytes(&self) -> u64 { + self.max_metadata_bytes + } + /// Refuse a configuration that cannot carry a credential safely or that /// could never produce a readable response. pub(crate) fn validate(&self) -> Result<(), EvidenceClientError> { @@ -148,16 +183,18 @@ impl EvidenceClientConfig { } } // The pinned key set is the load-bearing decision, so it fails here - // rather than once per request inside the verifier, where an empty set - // looks to an adopter like a deployment fault. - if self.trusted_jwks.keys.is_empty() { + // rather than once per request inside the verifier, where a set that + // could never verify anything looks to an adopter like a deployment + // fault. The rule is the verifier's own, asked once at the point the + // decision was made instead of restated here where it could drift. + if trusted_keys_are_usable(&self.trusted_jwks).is_err() { return Err(EvidenceClientError::configuration( - "the pinned key set must carry at least one verification key", + "the pinned key set must be one the verifier can use", )); } - if self.max_response_bytes == 0 { + if self.max_response_bytes == 0 || self.max_metadata_bytes == 0 { return Err(EvidenceClientError::configuration( - "the response bound must allow at least one byte", + "the response bounds must allow at least one byte", )); } if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { @@ -169,25 +206,6 @@ impl EvidenceClientConfig { } } -/// The base URL with any userinfo removed. -/// -/// [`EvidenceClientConfig::validate`] refuses a base URL carrying credentials, -/// but it runs inside `EvidenceClient::new`, so the rendering cannot rely on -/// having been reached after construction. -fn base_url_without_userinfo(base_url: &Url) -> Cow<'_, str> { - if base_url.username().is_empty() && base_url.password().is_none() { - return Cow::Borrowed(base_url.as_str()); - } - let mut stripped = base_url.clone(); - // Both setters refuse only a URL that cannot carry userinfo at all, and this - // point is reached only for a URL that carries some, so neither can refuse - // here. A refusal withholds the whole URL rather than rendering a credential. - if stripped.set_username("").is_err() || stripped.set_password(None).is_err() { - return Cow::Borrowed(""); - } - Cow::Owned(stripped.into()) -} - impl fmt::Debug for EvidenceClientConfig { /// The key set, the credential source, and the pinned certificate material /// are all withheld, as is any userinfo in the base URL. Only the @@ -200,6 +218,7 @@ impl fmt::Debug for EvidenceClientConfig { .field("connect_timeout", &self.connect_timeout) .field("user_agent", &self.user_agent) .field("max_response_bytes", &self.max_response_bytes) + .field("max_metadata_bytes", &self.max_metadata_bytes) .finish_non_exhaustive() } } @@ -339,17 +358,75 @@ mod tests { assert_eq!( config.validate().expect_err("an empty key set is refused"), EvidenceClientError::configuration( - "the pinned key set must carry at least one verification key" + "the pinned key set must be one the verifier can use" ) ); } + /// Emptiness is only one of the ways a pinned set can be unusable, and every + /// other way costs the adopter the same: a client that constructs, then + /// refuses every response for a reason that reads as a deployment fault. The + /// rule belongs to the verifier, so this asks the verifier rather than + /// restating what it accepts. + #[test] + fn a_pinned_key_set_the_verifier_could_never_use_is_refused_at_construction() { + let usable = one_key().keys[0].clone(); + let mut private_material = usable.clone(); + private_material["d"] = serde_json::json!("cHJpdmF0ZS1zY2FsYXItcGxhY2Vob2xkZXI"); + let mut absent_kid = usable.clone(); + absent_kid + .as_object_mut() + .expect("the key is an object") + .remove("kid"); + let mut empty_kid = usable.clone(); + empty_kid["kid"] = serde_json::json!(""); + for (description, keys) in [ + ("an empty set", vec![]), + ( + "private material a public set must never carry", + vec![private_material], + ), + ("a key with no identifier", vec![absent_kid]), + ("a key with an empty identifier", vec![empty_kid]), + ( + "two keys claiming one identifier", + vec![usable.clone(), usable.clone()], + ), + ( + "a key of an algorithm the profile does not fix", + vec![ + serde_json::json!({"kty": "EC", "crv": "P-256", "kid": "es256", + "alg": "ES256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}), + ], + ), + ] { + let mut config = config("https://evidence.example.org"); + config.trusted_jwks = JwksDocument { keys }; + let Err(error) = config.validate() else { + panic!("{description} was accepted"); + }; + assert_eq!( + error, + EvidenceClientError::configuration( + "the pinned key set must be one the verifier can use" + ), + "{description}" + ); + } + } + #[test] fn unusable_bounds_are_refused() { assert!(config("https://evidence.example.org") .with_max_response_bytes(0) .validate() .is_err()); + assert!(config("https://evidence.example.org") + .with_max_metadata_bytes(0) + .validate() + .is_err()); assert!(config("https://evidence.example.org") .with_request_timeout(Duration::ZERO) .validate() From af2aa00ee4b0ea4421f791ed40b0ee24114a469b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 13:46:21 +0700 Subject: [PATCH 60/67] test(evidence): cover the Python package shape a wheel installs Every other test in the suite imports the compiled extension straight from the bootstrap's own directory, as a top-level module. The hand-written `__init__.py`, which makes the directory a package and re-exports the extension as its submodule, was therefore never executed by any test, even though that is the only shape an installed wheel presents and the file does non-obvious work: it drops the submodule's own name so the surface matches the committed stub, and claims to stay correct under a reload. This assembles that layout from the cdylib the bootstrap already built and imports it in a subprocess with nothing else on the path, then checks each claim the file makes, that PEP 561's marker ships beside it, and that a refusal still reaches the caller as `ConfigurationError`. Assembling the top-level-extension layout instead fails it with the missing submodule, so the test distinguishes the two. Building an actual wheel would need maturin, which this crate's checks keep out of CI, so the layout is reproduced rather than built. Signed-off-by: Jeremi Joslin --- .../tests/python/test_package_layout.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 crates/registry-evidence-client-py/tests/python/test_package_layout.py diff --git a/crates/registry-evidence-client-py/tests/python/test_package_layout.py b/crates/registry-evidence-client-py/tests/python/test_package_layout.py new file mode 100644 index 000000000..0348ec155 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_package_layout.py @@ -0,0 +1,116 @@ +"""The installed package shape, which nothing else in this suite covers. + +Every other test imports the compiled extension directly from the bootstrap's +own directory, so it never runs `python/registry_evidence_client/__init__.py`: +the file that turns that directory into a package and re-exports the extension +as its submodule, which is the shape a built wheel installs. Building a wheel +here would require maturin, which this crate's checks deliberately keep out of +CI, so this assembles the same layout by hand from the cdylib the bootstrap +already built, and imports it in a subprocess where nothing else is on the path. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import registry_evidence_client as _extension # noqa: E402 + +_PACKAGE_SOURCE = ( + pathlib.Path(__file__).resolve().parents[2] / "python" / "registry_evidence_client" +) + +# Runs inside the assembled layout, and prints what it found as JSON. Each +# finding answers one claim `__init__.py` makes about itself. +_PROBE = """ +import importlib, json, pathlib, sys +import registry_evidence_client as revc + +findings = { + "imported_file": pathlib.Path(revc.__file__).name, + "has_client": hasattr(revc, "EvidenceClient"), + "has_configuration_error": hasattr(revc, "ConfigurationError"), + "submodule_name_dropped": not hasattr(revc, "registry_evidence_client"), + "py_typed_beside_init": ( + pathlib.Path(revc.__file__).parent / "py.typed" + ).is_file(), +} + +# The comment on the `pop` claims re-execution stays sound. A reload runs the +# body again against the existing namespace, where the submodule attribute is +# no longer bound. +importlib.reload(revc) +findings["reload_keeps_client"] = hasattr(revc, "EvidenceClient") +findings["reload_keeps_submodule_dropped"] = not hasattr( + revc, "registry_evidence_client" +) + +try: + revc.EvidenceClient("not-a-url", {"keys": []}, "test-token") +except revc.ConfigurationError as error: + findings["refusal_kind"] = error.kind + +print(json.dumps(findings)) +""" + + +class PackageLayoutTest(unittest.TestCase): + def test_the_package_layout_a_wheel_installs_imports_and_refuses(self): + # An extension module always reports the file it was loaded from; a + # missing one would mean the bootstrap imported something else. + self.assertIsNotNone(_extension.__file__) + extension = pathlib.Path(str(_extension.__file__)) + with tempfile.TemporaryDirectory() as root: + package = pathlib.Path(root) / "registry_evidence_client" + package.mkdir() + for name in ("__init__.py", "__init__.pyi", "py.typed"): + shutil.copyfile(_PACKAGE_SOURCE / name, package / name) + # The name maturin gives the extension inside the package: the + # submodule `__init__.py` imports, not a top-level module of the + # same name as the package. + shutil.copyfile(extension, package / "registry_evidence_client.so") + + environment = dict(os.environ) + # Only the assembled layout, so a stray copy elsewhere cannot be + # what answers the import. + environment["PYTHONPATH"] = root + completed = subprocess.run( + [sys.executable, "-c", _PROBE], + cwd=root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + f"importing the assembled package failed:\n{completed.stderr}", + ) + + findings = json.loads(completed.stdout) + self.assertEqual(findings["imported_file"], "__init__.py") + self.assertTrue(findings["has_client"]) + self.assertTrue(findings["has_configuration_error"]) + self.assertTrue(findings["submodule_name_dropped"]) + self.assertTrue(findings["py_typed_beside_init"]) + self.assertTrue(findings["reload_keeps_client"]) + self.assertTrue(findings["reload_keeps_submodule_dropped"]) + self.assertEqual(findings["refusal_kind"], "configuration") + + +if __name__ == "__main__": + unittest.main() From 915e9b7266a3093fc9c2ecddf5210374a57038e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 00:56:17 +0700 Subject: [PATCH 61/67] docs(evidence): teach relying-party integration in application code The consumer tutorial states the verification boundary abstractly. A relying party still has to decide where the trusted key set, the request policy, and the subject binding live in its own program. Walk that journey once with the Python client: give the application its own identity, pin the issuer keys out of band, build the expectations while the answer is still unknown, and read a value only after offline verification returns. The page is deliberately not registered in EVIDENCE_TUTORIALS: the executable tutorial gate mounts the repo read-only and injects only the Evidence binaries, so the Python extension module cannot be built inside it. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + .../request-evidence-from-an-application.mdx | 508 ++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 3517f9043..c7bc8a44a 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -337,6 +337,7 @@ export default defineConfig({ label: 'Verify and trust', collapsed: true, items: [ + { label: 'Request from an application', slug: 'tutorials/request-evidence-from-an-application' }, { label: 'Verify and retain an assertion', slug: 'tutorials/verify-an-assertion-as-a-consumer' }, { label: 'Enable SD-JWT VC', slug: 'tutorials/request-evidence-as-sd-jwt-vc' }, { label: 'Manage verifier trust', slug: 'tutorials/manage-evidence-verifier-trust' }, diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx new file mode 100644 index 000000000..02f3ceb00 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -0,0 +1,508 @@ +--- +title: Request Evidence from your application +description: Give an application its own identity, request an adult-status assertion with the Python Evidence client, and read the answer only after offline verification. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-06" +doc_type: tutorial +persona: + - consumer or verifier +locale: en +standards_referenced: [] +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +Complete [Get your first Evidence assertion](../first-evidence-assertion/) before starting this +tutorial. There you drove the Evidence boundary from a terminal with `evidencectl` and `curl`. Here +you move the same boundary into application code: your program obtains its own access token, sends +one request, and refuses to read the answer until the signed response has satisfied expectations the +program itself retained. + + + +## Understand what the client owns + +```mermaid +%%{init: {"sequence": {"mirrorActors": false}}}%% +sequenceDiagram + participant A as Your application + participant M as Registry Mint + participant E as Evidence + + A->>M: Signed client assertion + M-->>A: Short-lived access token + A->>E: One request, one fresh nonce + E-->>A: Signed response, still untrusted + A->>A: Verify against the retained procedure + Note over A: Only a verified payload reaches decision logic +``` + +The client library performs the token exchange, the request, and the verification. It does not +decide what a valid answer is. Your application states that once, as a relying procedure, and the +library refuses every response that does not match it. + +## Give the application its own identity + +Enter the existing project: + +```sh +cd adult-status +``` + +An application authenticates as a registered client, not as the project owner. Define a policy for +the question it may ask, then register the client: + +```sh +evidencectl access policy add age-checks --question adult-status +evidencectl access client add age-checker \ + --policy age-checks \ + --generate-local-key +``` + +```text +Added access policy age-checks for adult-status. +Added client age-checker with policy age-checks. +``` + +The reviewable registration at `access/clients/age-checker.yaml` carries the policy membership and +the public key. The private key stays owner-only at `.evidence/clients/age-checker/private.jwk` and +is the application's identity. The registration also fixes the audience the application must state +for itself: + +```sh +grep evidenceAudience access/clients/age-checker.yaml +``` + +```text +evidenceAudience: urn:registrystack:evidence:local:client:age-checker +``` + +A project with no access policy has an unnamed development caller. Registering the first policy +removes it, so from now on every request in this project names a client. +[Control who can request Evidence](../control-who-can-request-evidence/) covers policies, live +onboarding, and revocation in full. + +## Pin the keys your application trusts + +The application must decide which signing keys it accepts before any response exists. Build a JWKS +from the project's own retained public signing key: + +```sh +evidencectl jwks --out trusted-issuer-keys.json secrets/signing-ed25519-public.jwk.json +``` + +```text +wrote trusted-issuer-keys.json +``` + +In this tutorial the issuer and the relying party are the same person, so a local file stands in for +what production requires: keys received over a channel independent of the responses they verify. The +client never fetches trust from a response or from a discovery document. See +[Manage verifier trust and key rotation](../manage-evidence-verifier-trust/) for the production +handling. + +## Build the Python client + +The client is not published to a package index yet, so build the extension module from a checkout of +the repository. From inside the project directory: + +```sh +git clone --depth 1 https://github.com/registrystack/registry-stack.git ../registry-stack +cargo build --locked --manifest-path ../registry-stack/Cargo.toml \ + -p registry-evidence-client-py --lib \ + --features registry-evidence-client-py/extension-module +``` + +If you already have a checkout, point the two `../registry-stack` paths at it instead of cloning. + +Copy the compiled library into a directory the application imports from: + +```sh +mkdir -p python-module +case "$(uname -s)" in + Darwin) built=libregistry_evidence_client.dylib ;; + Linux) built=libregistry_evidence_client.so ;; +esac +cp "../registry-stack/target/debug/$built" python-module/registry_evidence_client.so +``` + +Python imports an extension module from a plain `.so` name on both platforms. macOS and Linux are +the platforms this build path covers. The build needs `python3` on `PATH`, because the binding +configures itself against the interpreter it will be imported by. + +## Start the local services + +Compile the question and the access policy into a fresh generation, and start Evidence and Registry +Mint: + +```sh +evidencectl dev --detach +``` + +```text +Evidence ready at http://127.0.0.1:8080 +Mint ready at http://127.0.0.1:8081 +``` + +The `registry.py` server from the first tutorial must still be running in its own terminal. Evidence +reads the source record through it. + +The access policy is now part of the running generation, so a terminal request names a client too. +Confirm that the project no longer accepts an unnamed one: + +```sh +evidencectl request prepare adult-status \ + --purpose age-check \ + --subject person_id=person-123 \ + --name unnamed-caller +``` + +```text +evidencectl: the active project requires a registered client selected with --client +``` + +## Read the definitions once + +Ask the deployment which complete request shapes this client may send: + +```sh +python3 - <<'PY' +import json +import sys +from pathlib import Path + +sys.path.insert(0, "python-module") + +from registry_evidence_client import EvidenceClient + +client = EvidenceClient( + base_url="http://127.0.0.1:8080", + trusted_jwks=json.loads(Path("trusted-issuer-keys.json").read_text()), + token={ + "private_key_jwt": { + "token_endpoint": "http://127.0.0.1:8081/token", + "client_id": "age-checker", + "client_key": json.loads( + Path(".evidence/clients/age-checker/private.jwk").read_text() + ), + }, + }, +) +print(json.dumps(client.discover(), indent=2, sort_keys=True)) +PY +``` + +```json +{ + "assuranceProfile": "local", + "configurationRevision": "sha256:", + "definitions": [ + { + "concepts": [ + { + "form": "boolean", + "id": "urn:registrystack:evidence:local:concept:adult-status:is_adult" + } + ], + "evidenceType": "urn:registrystack:evidence:local:evidence-type:adult-status", + "kind": "criterion", + "purpose": "age-check", + "referenceFrameworks": [ + "urn:registrystack:evidence:local:framework:adult-status" + ], + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "subjects": [ + { + "cardinality": "one", + "role": "person", + "selector": { + "fields": [ + { + "maximumBytes": 200, + "minimumBytes": 1, + "name": "person_id", + "type": "string" + } + ], + "profile": "local-subject-adult-status-v1", + "valueOrigin": "request" + } + } + ] + } + ], + "issuedBy": "urn:registrystack:evidence:local:issuer", + "providedBy": "urn:registrystack:evidence:local:provider", + "schema": "registry.evidence-definitions/v1" +} +``` + +Discovery is authenticated, and it grants no authority. It answers exactly one question: which +complete request shapes this client may send. It is not a trust anchor. Copy the values you need +into your code now. A request must never take an expectation from a discovery response fetched +alongside it. + +Four values in the procedure below do not come from discovery: + +- `audience` is the application's own registered identifier, from its client registration. +- `subject_expectations` is what the application already knows about the subject. +- `maximum_assertion_lifetime_seconds` and `clock_skew_seconds` are the application's own bounds on + how stale an answer it accepts. + +`configurationRevision` is worth pinning deliberately. When the deployment's governed configuration +changes, the revision changes with it, verification fails until the procedure has been reviewed, and +your application refuses rather than silently accepting a different question's answer. + +## Write the relying procedure + +Open `age_check.py` in your editor and add the application. Substitute the +`configuration_revision` value from your own discovery output: + +```python +import json +import sys +from pathlib import Path + +sys.path.insert(0, "python-module") + +from registry_evidence_client import ( + DeniedError, + EvidenceClient, + EvidenceClientError, + NotAvailableError, + VerificationError, +) + +# Read once from the published definitions while writing this procedure. A +# request never takes an expectation from a fresh discovery response. +PROCEDURE = { + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "audience": "urn:registrystack:evidence:local:client:age-checker", + "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", + "issued_by": "urn:registrystack:evidence:local:issuer", + "provided_by": "urn:registrystack:evidence:local:provider", + "configuration_revision": "sha256:", + "expected_outputs": [ + { + "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean", + }, + ], + "expected_assurance_profile": "local", + "maximum_assertion_lifetime_seconds": 300, + "clock_skew_seconds": 30, +} +IS_ADULT = "urn:registrystack:evidence:local:concept:adult-status:is_adult" +SELECTOR_PROFILE = "local-subject-adult-status-v1" +BINDINGS = Path("subject-bindings.json") + + +def build_client(): + """Configure the one deployment this application talks to.""" + return EvidenceClient( + base_url="http://127.0.0.1:8080", + trusted_jwks=json.loads(Path("trusted-issuer-keys.json").read_text()), + token={ + "private_key_jwt": { + "token_endpoint": "http://127.0.0.1:8081/token", + "client_id": "age-checker", + "client_key": json.loads( + Path(".evidence/clients/age-checker/private.jwk").read_text() + ), + }, + }, + ) + + +def expectations_for(person_id): + """Pin a binding this application has already seen, or accept first use.""" + if BINDINGS.exists(): + return json.loads(BINDINGS.read_text()).get(person_id, "accept_first_use") + return "accept_first_use" + + +def remember(person_id, pinned): + store = json.loads(BINDINGS.read_text()) if BINDINGS.exists() else {} + store[person_id] = pinned + BINDINGS.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n") + + +def ask_is_adult(client, person_id): + spec = dict( + PROCEDURE, + subjects=[ + { + "role": "person", + "selector_profile": SELECTOR_PROFILE, + "selector_values": {"person_id": person_id}, + }, + ], + subject_expectations=expectations_for(person_id), + ) + verified = client.request_and_verify(client.prepare(spec)) + answers = { + value["providesValueFor"]: value["value"] + for value in verified.evidence["supportedValues"] + } + return answers[IS_ADULT], verified.pinned_subject_expectations + + +person_id = sys.argv[1] if len(sys.argv) > 1 else "person-123" +try: + is_adult, pinned = ask_is_adult(build_client(), person_id) +except VerificationError as error: + sys.exit(f"unverifiable response, nothing read ({error.code}): {error}") +except DeniedError as error: + sys.exit(f"refused by the deployment (status {error.status}): {error}") +except NotAvailableError as error: + sys.exit(f"no evidence available: {error}") +except EvidenceClientError as error: + sys.exit(f"exchange did not complete ({error.kind}): {error}") + +remember(person_id, pinned) +print(f"{person_id} is_adult={is_adult}") +print(f"pinned binding recorded in {BINDINGS}") +``` + +Four properties of that code are the point of this tutorial: + +- `prepare` performs no network call. It closes the request, generates a fresh nonce, and builds the + verification policy while the answer is still unknown. A prepared request is good for one send. +- `request_and_verify` returns only after the response satisfied the policy. There is no object in + this program that holds a decoded but unverified payload. +- The answer is read from a mapping keyed by the concept identifier, never by position, so a + response carrying different values cannot be misread as this one. +- Three failures get their own branch, and `EvidenceClientError` catches the rest. Every path exits. + Nothing falls through to a default answer. + +## Run it + +The recorded subject binding is scoped to this audience and purpose, so keep it owner-only: + +```sh +umask 077 +python3 age_check.py +``` + +```text +person-123 is_adult=True +pinned binding recorded in subject-bindings.json +``` + +The first run had nothing to pin, so it accepted the binding on first use and recorded it. Run it +again: + +```sh +python3 age_check.py +``` + +```text +person-123 is_adult=True +pinned binding recorded in subject-bindings.json +``` + +The second run pinned the recorded binding, and the response had to carry that exact value. The +binding is stable for the same subject, audience, and purpose, and unrelated for any other audience +or purpose. First use proves only that a response was signed for the request that was sent; pinning +is what ties later answers to the same subject your application saw before. + +Ask about a different record: + +```sh +python3 age_check.py person-456 +``` + +```text +person-456 is_adult=False +pinned binding recorded in subject-bindings.json +``` + +The registry holds a name and a date of birth for both people. Neither answer contains either. + +## Refuse before reading + +Change one stored binding to prove that the application, not the deployment, decides what it +accepts: + +```sh +python3 - <<'PY' +import json +from pathlib import Path + +store = json.loads(Path("subject-bindings.json").read_text()) +store["person-123"] = store["person-456"] +Path("subject-bindings.json").write_text(json.dumps(store, indent=2, sort_keys=True) + "\n") +PY +python3 age_check.py person-123 +``` + +```text +unverifiable response, nothing read (policy): the Evidence response failed verification: Evidence payload does not match the relying procedure +``` + +Evidence answered the request successfully. The client discarded the response because it did not +match the retained expectation, and `age_check.py` exited without reading a value. A stale +`configuration_revision` fails the same way, and for the same reason. + +Delete `subject-bindings.json` to start the pinning over. + +Branch on the exception class or on `kind`, never on the message text, which is not frozen: + +| `kind` | Class | Meaning | +| --- | --- | --- | +| `configuration` | `ConfigurationError` | The client cannot be used as configured, or a prepared request was already sent. | +| `nonce` | `NonceError` | The request nonce could not be generated. | +| `token` | `TokenError` | No credential could be obtained. Read `token_kind`. | +| `transport` | `TransportError` | The exchange failed below the HTTP layer. Read `transport_kind`. | +| `denied` | `DeniedError` | The deployment refused with a coded problem response. | +| `not_available` | `NotAvailableError` | The deployment answered that no evidence is available. | +| `protocol` | `ProtocolError` | The deployment answered outside its contract, or the response could not be parsed. | +| `verification` | `VerificationError` | A signed response failed offline verification. Read `code`. | + +Every class above inherits from `EvidenceClientError`, which carries `kind`. HTTP 401, 403, and 429 +all map to `denied`, whatever the response body's own code says, and every other non-2xx status maps +to `protocol`. No exception carries response bytes, a credential, a header value, a selector value, +or a subject binding. + +Two failures sit outside that hierarchy, because neither is a mapped failure of the exchange: the +client's internal runtime failing to start raises `RuntimeError`, and a serialization failure on a +value the client itself built raises `ValueError`. + +## Stop the local services + +```sh +evidencectl dev stop +evidencectl dev clean +``` + +```text +Local Evidence stopped +Removed stopped local Evidence state +``` + +This keeps `age_check.py`, the pinned JWKS, the recorded bindings, and the client's private key. +Return to the first terminal and press `Ctrl+C` to stop `registry.py`. + +## Next + +- [Verify Evidence as a consumer](../verify-an-assertion-as-a-consumer/), for re-verifying a stored + response at the recorded decision time +- [Control who can request Evidence](../control-who-can-request-evidence/), for policies and + revocation +- [Request an access token from your own code](../../configure/request-an-access-token/), for the + token exchange without the client library +- [Review the Evidence Gateway API](../../reference/apis/registry-evidence/) From 033f7036bc527b0202dbc6ea3b608c1a44a57fab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 10:43:15 +0700 Subject: [PATCH 62/67] docs(evidence): pin the relying procedure from discovery Copying a 64-hex configuration revision into source by hand is a poor first experience and invites the reader to keep re-copying it whenever verification starts failing. Save the discovery document instead, transform it offline into a procedure.json the application owns, and load that file at startup. The pinning act stays explicit and still happens before any answer is read; it is only automated instead of transcribed. Regenerating is documented as a review step, with the diff to look at, not a retry. expected_outputs stays hand-written: a concept's published form and a verification expectation's form are separate vocabularies, so deriving one from the other would work for this requirement and mislead on the next. The generator checks the concept identifiers against discovery so a deployment that stops publishing one fails at review time. Signed-off-by: Jeremi Joslin --- .../request-evidence-from-an-application.mdx | 167 ++++++++++++++---- 1 file changed, 128 insertions(+), 39 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index 02f3ceb00..876b7e374 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -176,7 +176,8 @@ evidencectl: the active project requires a registered client selected with --cli ## Read the definitions once -Ask the deployment which complete request shapes this client may send: +Ask the deployment which complete request shapes this client may send, and keep the answer to +review: ```sh python3 - <<'PY' @@ -201,7 +202,9 @@ client = EvidenceClient( }, }, ) -print(json.dumps(client.discover(), indent=2, sort_keys=True)) +document = json.dumps(client.discover(), indent=2, sort_keys=True) +Path("discovery.json").write_text(document + "\n") +print(document) PY ``` @@ -251,25 +254,125 @@ PY ``` Discovery is authenticated, and it grants no authority. It answers exactly one question: which -complete request shapes this client may send. It is not a trust anchor. Copy the values you need -into your code now. A request must never take an expectation from a discovery response fetched -alongside it. +complete request shapes this client may send. It is not a trust anchor, and a request must never +take an expectation from a discovery response fetched alongside it. -Four values in the procedure below do not come from discovery: +Read it here once, to author the procedure. From now on the procedure supplies every request's +expectations. -- `audience` is the application's own registered identifier, from its client registration. -- `subject_expectations` is what the application already knows about the subject. -- `maximum_assertion_lifetime_seconds` and `clock_skew_seconds` are the application's own bounds on - how stale an answer it accepts. +## Pin the procedure -`configurationRevision` is worth pinning deliberately. When the deployment's governed configuration -changes, the revision changes with it, verification fails until the procedure has been reviewed, and -your application refuses rather than silently accepting a different question's answer. +Write what you just reviewed into a file the application owns. This step makes no network call: it +transforms the document you already read, so the identifiers and the revision are transcribed rather +than copied by hand. The constants at the top are the application's own, stated rather than read: + +```sh +python3 - <<'PY' +import json +import sys +from pathlib import Path + +REQUIREMENT = "urn:registrystack:evidence:local:requirement:adult-status" + +# Chosen by this application, not published by the deployment. +AUDIENCE = "urn:registrystack:evidence:local:client:age-checker" +EXPECTED_OUTPUTS = [ + { + "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean", + }, +] +MAXIMUM_LIFETIME_SECONDS = 300 +CLOCK_SKEW_SECONDS = 30 + +published = json.loads(Path("discovery.json").read_text()) +definition = next( + (item for item in published["definitions"] if item["requirement"] == REQUIREMENT), + None, +) +if definition is None: + sys.exit(f"this client may not request {REQUIREMENT}") + +# Fail here, at review time, rather than at verification time. +offered = {concept["id"] for concept in definition["concepts"]} +absent = [item["concept"] for item in EXPECTED_OUTPUTS if item["concept"] not in offered] +if absent: + sys.exit(f"the deployment no longer publishes {', '.join(absent)}") + +document = json.dumps( + { + "requirement": definition["requirement"], + "purpose": definition["purpose"], + "evidence_type": definition["evidenceType"], + "issued_by": published["issuedBy"], + "provided_by": published["providedBy"], + "configuration_revision": published["configurationRevision"], + "expected_assurance_profile": published["assuranceProfile"], + "audience": AUDIENCE, + "expected_outputs": EXPECTED_OUTPUTS, + "maximum_assertion_lifetime_seconds": MAXIMUM_LIFETIME_SECONDS, + "clock_skew_seconds": CLOCK_SKEW_SECONDS, + }, + indent=2, + sort_keys=True, +) +Path("procedure.json").write_text(document + "\n") +print(document) +PY +``` + +```json +{ + "audience": "urn:registrystack:evidence:local:client:age-checker", + "clock_skew_seconds": 30, + "configuration_revision": "sha256:", + "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", + "expected_assurance_profile": "local", + "expected_outputs": [ + { + "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean" + } + ], + "issued_by": "urn:registrystack:evidence:local:issuer", + "maximum_assertion_lifetime_seconds": 300, + "provided_by": "urn:registrystack:evidence:local:provider", + "purpose": "age-check", + "requirement": "urn:registrystack:evidence:local:requirement:adult-status" +} +``` + +`procedure.json` is the pinned procedure. In a real deployment you review it, commit it, and ship it +with the application. You do not regenerate it at startup: an application that refreshes its +expectations from the deployment it is checking has no expectations of its own. + +Three of those values are the application's own judgement, and no deployment can supply them: + +- `audience` is the identifier the client registration assigned this application. +- `maximum_assertion_lifetime_seconds` and `clock_skew_seconds` are its own bounds on how stale an + answer it will accept. + +`expected_outputs` is stated by hand for a different reason. A concept's published `form` and a +verification expectation's `form` are separate vocabularies: a boolean concept is expected as +`boolean`, but controlled codes and bounded decimals are expected as `string`, bounded integers as +`integer`, and the two list forms need explicit bounds. Deriving the expectation from the published +form would work for this requirement and mislead you on the next one. The concept identifiers are +still checked against discovery above, so a deployment that stops publishing one fails at review +time rather than at verification time. + +`subject_expectations` is absent from the file because it is per-request: it is what the application +already knows about the subject in front of it. + +Regenerating this file is a review step, not a retry. The revision covers the deployment's entire +governed configuration, so it moves whenever an operator changes any of it, and verification then +fails until someone has looked at what changed. When that happens, keep the reviewed copy, write a +new one, and `diff` them before accepting: a changed revision alone is routine, a changed +`evidence_type`, `issued_by`, or concept set means the question itself moved. ## Write the relying procedure -Open `age_check.py` in your editor and add the application. Substitute the -`configuration_revision` value from your own discovery output: +Open `age_check.py` in your editor and add the application. It loads the pinned procedure and never +calls discovery again: ```python import json @@ -286,26 +389,9 @@ from registry_evidence_client import ( VerificationError, ) -# Read once from the published definitions while writing this procedure. A -# request never takes an expectation from a fresh discovery response. -PROCEDURE = { - "requirement": "urn:registrystack:evidence:local:requirement:adult-status", - "purpose": "age-check", - "audience": "urn:registrystack:evidence:local:client:age-checker", - "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", - "issued_by": "urn:registrystack:evidence:local:issuer", - "provided_by": "urn:registrystack:evidence:local:provider", - "configuration_revision": "sha256:", - "expected_outputs": [ - { - "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", - "form": "boolean", - }, - ], - "expected_assurance_profile": "local", - "maximum_assertion_lifetime_seconds": 300, - "clock_skew_seconds": 30, -} +# The reviewed procedure. This program never calls discovery: every expectation +# comes from the file that was pinned when it was written. +PROCEDURE = json.loads(Path("procedure.json").read_text()) IS_ADULT = "urn:registrystack:evidence:local:concept:adult-status:is_adult" SELECTOR_PROFILE = "local-subject-adult-status-v1" BINDINGS = Path("subject-bindings.json") @@ -378,8 +464,10 @@ print(f"{person_id} is_adult={is_adult}") print(f"pinned binding recorded in {BINDINGS}") ``` -Four properties of that code are the point of this tutorial: +Five properties of that code are the point of this tutorial: +- Every expectation comes from `procedure.json`. The program holds no discovery client and cannot + learn what to expect from the deployment it is checking. - `prepare` performs no network call. It closes the request, generates a fresh nonce, and builds the verification policy while the answer is still unknown. A prepared request is good for one send. - `request_and_verify` returns only after the response satisfied the policy. There is no object in @@ -455,8 +543,8 @@ unverifiable response, nothing read (policy): the Evidence response failed verif ``` Evidence answered the request successfully. The client discarded the response because it did not -match the retained expectation, and `age_check.py` exited without reading a value. A stale -`configuration_revision` fails the same way, and for the same reason. +match the retained expectation, and `age_check.py` exited without reading a value. Editing the +`configuration_revision` in `procedure.json` fails the same way, and for the same reason. Delete `subject-bindings.json` to start the pinning over. @@ -494,7 +582,8 @@ Local Evidence stopped Removed stopped local Evidence state ``` -This keeps `age_check.py`, the pinned JWKS, the recorded bindings, and the client's private key. +This keeps `age_check.py`, `discovery.json`, `procedure.json`, the pinned JWKS, the recorded +bindings, and the client's private key. Return to the first terminal and press `Ctrl+C` to stop `registry.py`. ## Next From 4fb65fdbb741b7031bfff0789a34ab4a31c37f3e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 14:20:55 +0700 Subject: [PATCH 63/67] docs(evidence): correct the application tutorial's ids and claims Review surfaced eight defects, each confirmed against source: - the policy and client ids collided with the access-control tutorial, so authoring refused for a reader who had followed the other page; both pages now carry their own ids and this one says why they compose - the source clone was unpinned, so a reader could build the client from a tree that need not match the installed evidencectl; it now pins the tag of the installed version - the review step accepted a subset of the published concepts, which the verifier rejects at request time; it now requires the exact set - the application hard-coded the selector profile instead of reading the shape it had reviewed - the binding-stability prose named only subject and audience, while the MAC also covers purpose, role, selector profile, and the deployment's binding key and key version - the bindings store dropped a concurrent run's entry, and its replacement stays owner-only whatever umask the shell carries - the page never restarted the registry the prerequisite leaves stopped, and evidencectl dev reports ready without reaching the source - the error-mapping prose implied any body code maps, where the contract honors a code only under a status registered for it Verified by replaying all thirty-one fences against binaries built from this checkout: every documented output matches apart from the deliberate revision placeholder. docs/site npm test and npm run check pass. Signed-off-by: Jeremi Joslin --- .../request-evidence-from-an-application.mdx | 225 +++++++++++++----- 1 file changed, 166 insertions(+), 59 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index 876b7e374..a3652385e 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -27,7 +27,7 @@ program itself retained. level="Local development with synthetic data" prerequisites={[ 'The completed first Evidence assertion tutorial', - 'Its adult-status project and running registry.py', + 'Its adult-status project and its registry.py, which you restart here', 'Python 3.10 or later', 'A Rust toolchain and git, to build the client from source', ]} @@ -66,28 +66,34 @@ An application authenticates as a registered client, not as the project owner. D the question it may ask, then register the client: ```sh -evidencectl access policy add age-checks --question adult-status -evidencectl access client add age-checker \ - --policy age-checks \ +evidencectl access policy add app-age-checks --question adult-status +evidencectl access client add age-check-app \ + --policy app-age-checks \ --generate-local-key ``` ```text -Added access policy age-checks for adult-status. -Added client age-checker with policy age-checks. +Added access policy app-age-checks for adult-status. +Added client age-check-app with policy app-age-checks. ``` -The reviewable registration at `access/clients/age-checker.yaml` carries the policy membership and -the public key. The private key stays owner-only at `.evidence/clients/age-checker/private.jwk` and -is the application's identity. The registration also fixes the audience the application must state -for itself: +Both identifiers belong to this tutorial alone. Authoring refuses to overwrite an existing policy or +client document, so a page that reused the ids from +[Control who can request Evidence](../control-who-can-request-evidence/) would refuse for anyone who +had followed it, and would inherit the client that tutorial revokes at its end. With their own ids, +the two pages compose in one project in either order. + +The reviewable registration at `access/clients/age-check-app.yaml` carries the policy membership and +the public key. The private key stays owner-only at `.evidence/clients/age-check-app/private.jwk` +and is the application's identity. The registration also fixes the audience the application must +state for itself: ```sh -grep evidenceAudience access/clients/age-checker.yaml +grep evidenceAudience access/clients/age-check-app.yaml ``` ```text -evidenceAudience: urn:registrystack:evidence:local:client:age-checker +evidenceAudience: urn:registrystack:evidence:local:client:age-check-app ``` A project with no access policy has an unnamed development caller. Registering the first policy @@ -117,16 +123,25 @@ handling. ## Build the Python client The client is not published to a package index yet, so build the extension module from a checkout of -the repository. From inside the project directory: +the repository. Take the source at the version of the runtime you installed, not at the default +branch. From inside the project directory: ```sh -git clone --depth 1 https://github.com/registrystack/registry-stack.git ../registry-stack +installed="$(evidencectl --version | awk '{print $2}')" +git clone --depth 1 --branch "v$installed" \ + https://github.com/registrystack/registry-stack.git ../registry-stack cargo build --locked --manifest-path ../registry-stack/Cargo.toml \ -p registry-evidence-client-py --lib \ --features registry-evidence-client-py/extension-module ``` -If you already have a checkout, point the two `../registry-stack` paths at it instead of cloning. +This project is pre-1.0, so its default branch may already carry request and response contract +changes the installed runtime does not implement. Pinning the checkout to the installed version keeps +the client and the deployment on one contract; a mismatch would surface as a discovery or +verification failure that looks like a bug in your application. If the clone fails because that +release does not carry the client yet, install a newer `evidencectl` and repeat the step. If you +already have a checkout, point the two `../registry-stack` paths at it instead of cloning, after +confirming its `version` in `Cargo.toml` matches `evidencectl --version`. Copy the compiled library into a directory the application imports from: @@ -145,8 +160,20 @@ configures itself against the interpreter it will be imported by. ## Start the local services -Compile the question and the access policy into a fresh generation, and start Evidence and Registry -Mint: +Evidence reads the source record through the `registry.py` server from the first tutorial, whose +cleanup told you to stop it. Start it again in its own terminal, from the project directory, and +leave it running: + +```sh +python3 registry.py +``` + +```text +Registry listening on http://127.0.0.1:8000 +``` + +Back in the first terminal, compile the question and the access policy into a fresh generation, and +start Evidence and Registry Mint: ```sh evidencectl dev --detach @@ -157,8 +184,8 @@ Evidence ready at http://127.0.0.1:8080 Mint ready at http://127.0.0.1:8081 ``` -The `registry.py` server from the first tutorial must still be running in its own terminal. Evidence -reads the source record through it. +`evidencectl dev` reports ready without reaching the source, so a stopped registry surfaces only +later, as a failed evidence request. The access policy is now part of the running generation, so a terminal request names a client too. Confirm that the project no longer accepts an unnamed one: @@ -195,9 +222,9 @@ client = EvidenceClient( token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", - "client_id": "age-checker", + "client_id": "age-check-app", "client_key": json.loads( - Path(".evidence/clients/age-checker/private.jwk").read_text() + Path(".evidence/clients/age-check-app/private.jwk").read_text() ), }, }, @@ -275,7 +302,7 @@ from pathlib import Path REQUIREMENT = "urn:registrystack:evidence:local:requirement:adult-status" # Chosen by this application, not published by the deployment. -AUDIENCE = "urn:registrystack:evidence:local:client:age-checker" +AUDIENCE = "urn:registrystack:evidence:local:client:age-check-app" EXPECTED_OUTPUTS = [ { "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", @@ -286,18 +313,34 @@ MAXIMUM_LIFETIME_SECONDS = 300 CLOCK_SKEW_SECONDS = 30 published = json.loads(Path("discovery.json").read_text()) -definition = next( - (item for item in published["definitions"] if item["requirement"] == REQUIREMENT), - None, -) -if definition is None: - sys.exit(f"this client may not request {REQUIREMENT}") - -# Fail here, at review time, rather than at verification time. -offered = {concept["id"] for concept in definition["concepts"]} -absent = [item["concept"] for item in EXPECTED_OUTPUTS if item["concept"] not in offered] -if absent: - sys.exit(f"the deployment no longer publishes {', '.join(absent)}") +shapes = [item for item in published["definitions"] if item["requirement"] == REQUIREMENT] +if len(shapes) != 1: + sys.exit(f"expected exactly one published shape for {REQUIREMENT}, found {len(shapes)}") +definition = shapes[0] + +# Fail here, at review time, rather than at verification time. Verification +# requires the response's value set to match the expectation exactly, so a +# concept this application does not expect is as disqualifying as a missing one. +offered = {concept["id"]: concept["form"] for concept in definition["concepts"]} +expected = {item["concept"]: item["form"] for item in EXPECTED_OUTPUTS} +if offered.keys() != expected.keys(): + missing = sorted(expected.keys() - offered.keys()) + added = sorted(offered.keys() - expected.keys()) + sys.exit(f"the published concept set moved: no longer published {missing}, now also {added}") + +# One subject, one string selector field, resolved from the request: the shape +# this application is written for. A deployment may change any of it while +# keeping the identifiers above. +[subject] = definition["subjects"] +selector = subject["selector"] +fields = {field["name"] for field in selector["fields"]} +if ( + subject["cardinality"] != "one" + or subject["role"] != "person" + or selector["valueOrigin"] != "request" + or fields != {"person_id"} +): + sys.exit(f"the published subject shape moved: {json.dumps(subject, sort_keys=True)}") document = json.dumps( { @@ -312,6 +355,16 @@ document = json.dumps( "expected_outputs": EXPECTED_OUTPUTS, "maximum_assertion_lifetime_seconds": MAXIMUM_LIFETIME_SECONDS, "clock_skew_seconds": CLOCK_SKEW_SECONDS, + # What the deployment published, so a later regeneration diffs it. The + # application reads the subject shape from here rather than restating it. + "published_shape": { + "concepts": offered, + "subject": { + "role": subject["role"], + "selector_profile": selector["profile"], + "selector_fields": sorted(fields), + }, + }, }, indent=2, sort_keys=True, @@ -323,7 +376,7 @@ PY ```json { - "audience": "urn:registrystack:evidence:local:client:age-checker", + "audience": "urn:registrystack:evidence:local:client:age-check-app", "clock_skew_seconds": 30, "configuration_revision": "sha256:", "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", @@ -337,6 +390,18 @@ PY "issued_by": "urn:registrystack:evidence:local:issuer", "maximum_assertion_lifetime_seconds": 300, "provided_by": "urn:registrystack:evidence:local:provider", + "published_shape": { + "concepts": { + "urn:registrystack:evidence:local:concept:adult-status:is_adult": "boolean" + }, + "subject": { + "role": "person", + "selector_fields": [ + "person_id" + ], + "selector_profile": "local-subject-adult-status-v1" + } + }, "purpose": "age-check", "requirement": "urn:registrystack:evidence:local:requirement:adult-status" } @@ -356,9 +421,17 @@ Three of those values are the application's own judgement, and no deployment can verification expectation's `form` are separate vocabularies: a boolean concept is expected as `boolean`, but controlled codes and bounded decimals are expected as `string`, bounded integers as `integer`, and the two list forms need explicit bounds. Deriving the expectation from the published -form would work for this requirement and mislead you on the next one. The concept identifiers are -still checked against discovery above, so a deployment that stops publishing one fails at review -time rather than at verification time. +form would work for this requirement and mislead you on the next one. + +The concept set is checked against discovery in both directions instead, because verification is +exact: it requires the response's value set to match `expected_outputs` one for one. A deployment +that stops publishing a concept and one that adds another both leave this application unable to +verify any response, so both fail at review time here rather than at verification time later. + +`published_shape` is the part of the answer the deployment owns, kept in the file so the next review +can see it move. The application reads the subject role and selector profile from it rather than +restating them, so a changed request shape cannot pass review and then reach a request. The +`concepts` map records the forms the deployment published beside the expectations authored from them. `subject_expectations` is absent from the file because it is per-request: it is what the application already knows about the subject in front of it. @@ -366,8 +439,9 @@ already knows about the subject in front of it. Regenerating this file is a review step, not a retry. The revision covers the deployment's entire governed configuration, so it moves whenever an operator changes any of it, and verification then fails until someone has looked at what changed. When that happens, keep the reviewed copy, write a -new one, and `diff` them before accepting: a changed revision alone is routine, a changed -`evidence_type`, `issued_by`, or concept set means the question itself moved. +new one, and `diff` them before accepting: a changed revision alone is routine, while a changed +`evidence_type`, `issued_by`, concept set, or `published_shape` means the question, or the way it +must be asked, moved. ## Write the relying procedure @@ -375,7 +449,9 @@ Open `age_check.py` in your editor and add the application. It loads the pinned calls discovery again: ```python +import fcntl import json +import os import sys from pathlib import Path @@ -392,9 +468,12 @@ from registry_evidence_client import ( # The reviewed procedure. This program never calls discovery: every expectation # comes from the file that was pinned when it was written. PROCEDURE = json.loads(Path("procedure.json").read_text()) +# The reviewed request shape. Removed from the procedure because `prepare` takes +# the expectations and the subjects, and this is neither. +SUBJECT = PROCEDURE.pop("published_shape")["subject"] IS_ADULT = "urn:registrystack:evidence:local:concept:adult-status:is_adult" -SELECTOR_PROFILE = "local-subject-adult-status-v1" BINDINGS = Path("subject-bindings.json") +BINDINGS_LOCK = Path("subject-bindings.lock") def build_client(): @@ -405,9 +484,9 @@ def build_client(): token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", - "client_id": "age-checker", + "client_id": "age-check-app", "client_key": json.loads( - Path(".evidence/clients/age-checker/private.jwk").read_text() + Path(".evidence/clients/age-check-app/private.jwk").read_text() ), }, }, @@ -422,9 +501,20 @@ def expectations_for(person_id): def remember(person_id, pinned): - store = json.loads(BINDINGS.read_text()) if BINDINGS.exists() else {} - store[person_id] = pinned - BINDINGS.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n") + """Add one binding under an exclusive lock, without dropping another run's.""" + with BINDINGS_LOCK.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + store = json.loads(BINDINGS.read_text()) if BINDINGS.exists() else {} + store[person_id] = pinned + pending = BINDINGS.with_name(BINDINGS.name + ".pending") + document = json.dumps(store, indent=2, sort_keys=True) + "\n" + # The replacement is a new file, so its permissions come from this call + # and not from the store it replaces. Owner-only from creation, whatever + # umask the shell that runs this happens to carry. + descriptor = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with open(descriptor, "w") as pending_file: + pending_file.write(document) + pending.replace(BINDINGS) def ask_is_adult(client, person_id): @@ -432,8 +522,8 @@ def ask_is_adult(client, person_id): PROCEDURE, subjects=[ { - "role": "person", - "selector_profile": SELECTOR_PROFILE, + "role": SUBJECT["role"], + "selector_profile": SUBJECT["selector_profile"], "selector_values": {"person_id": person_id}, }, ], @@ -464,10 +554,10 @@ print(f"{person_id} is_adult={is_adult}") print(f"pinned binding recorded in {BINDINGS}") ``` -Five properties of that code are the point of this tutorial: +Six properties of that code are the point of this tutorial: -- Every expectation comes from `procedure.json`. The program holds no discovery client and cannot - learn what to expect from the deployment it is checking. +- Every expectation, and the request shape itself, comes from `procedure.json`. The program holds no + discovery client and cannot learn what to expect from the deployment it is checking. - `prepare` performs no network call. It closes the request, generates a fresh nonce, and builds the verification policy while the answer is still unknown. A prepared request is good for one send. - `request_and_verify` returns only after the response satisfied the policy. There is no object in @@ -476,6 +566,10 @@ Five properties of that code are the point of this tutorial: response carrying different values cannot be misread as this one. - Three failures get their own branch, and `EvidenceClientError` catches the rest. Every path exits. Nothing falls through to a default answer. +- `remember` reads and rewrites the whole store, so it holds an exclusive lock across both and + replaces the file atomically. Two runs finishing at once would otherwise each write what they read, + and the loser's subject would silently return to accepting a binding on first use. A file lock + covers one host; an application on more than one needs a transactional per-subject store. ## Run it @@ -503,10 +597,19 @@ person-123 is_adult=True pinned binding recorded in subject-bindings.json ``` -The second run pinned the recorded binding, and the response had to carry that exact value. The -binding is stable for the same subject, audience, and purpose, and unrelated for any other audience -or purpose. First use proves only that a response was signed for the request that was sent; pinning -is what ties later answers to the same subject your application saw before. +The second run pinned the recorded binding, and the response had to carry that exact value. First use +proves only that a response was signed for the request that was sent; pinning is what ties later +answers to the same subject your application saw before. + +The binding is a keyed one-way value the deployment computes, so its stability has a scope. It is +stable for the same subject while the audience, the purpose, the role, the selector profile, and the +deployment's own binding key and key version are all unchanged, and it is unrelated for any other +audience or purpose. An operator who rotates that key, or increments its version, changes every +binding the deployment issues. Treat that as a coordinated event rather than a mismatch: the +deployment announces it, and each application re-enrolls by discarding its stored bindings and +accepting first use once more per subject. Discarding them without that announcement gives up exactly +the continuity the pinning provides, and a changed selector profile has the same effect, which is why +the review step above refuses one. Ask about a different record: @@ -561,10 +664,14 @@ Branch on the exception class or on `kind`, never on the message text, which is | `protocol` | `ProtocolError` | The deployment answered outside its contract, or the response could not be parsed. | | `verification` | `VerificationError` | A signed response failed offline verification. Read `code`. | -Every class above inherits from `EvidenceClientError`, which carries `kind`. HTTP 401, 403, and 429 -all map to `denied`, whatever the response body's own code says, and every other non-2xx status maps -to `protocol`. No exception carries response bytes, a credential, a header value, a selector value, -or a subject binding. +Every class above inherits from `EvidenceClientError`, which carries `kind`. The status and the body's +`code` decide the class together, and only for the pairs the problem contract registers: HTTP 401, +403, and 429 map to `denied`, and 422 carrying the contract's no-evidence code maps to +`not_available`. Every other status maps to `protocol`, as does any of those four statuses carrying a +code the contract does not register for it, because that is a body the deployment did not promise. So +`not_available` is the branch for a request that was answered and had no evidence to report, not a +protocol failure. No exception carries response bytes, a credential, a header value, a selector +value, or a subject binding. Two failures sit outside that hierarchy, because neither is a mapped failure of the exchange: the client's internal runtime failing to start raises `RuntimeError`, and a serialization failure on a From fd077e8e1bb41d2cb4669d2a6239bb8af11ff553 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 14:48:49 +0700 Subject: [PATCH 64/67] fix(evidence): bound the advertised skew to what a policy may express A deployment advertises `signing.verifierClockSkewSeconds` so a relying party can adopt it, and a relying party expresses what it adopted as the verification policy's `clockSkewSeconds`. The two bounds disagreed: the bundle contract and startup validation accepted up to 600 seconds, while no conformant verification policy could express more than 300, so a deployment could advertise skew advice its own relying parties had to refuse. The bundle contract and `SigningConfig::validate` now carry the policy's 300. A test reads both maxima from the contracts rather than restating them, and pins startup validation to the contract at the boundary and one above it, so moving either bound alone fails. Security review notes: this narrows a deployment acceptance bound, it does not widen one. Clock skew only ever widens the window in which an assertion is accepted, so the affected direction is the safe one: a bundle that previously validated with 301..=600 now fails at startup instead of advertising unusable advice. Every `verifierClockSkewSeconds` in tree is 30, and no generated contract artifact or published document restates the old maximum, so nothing else moves with it. Signing, verification, and evidence construction are untouched. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/config.rs | 67 ++++++++++++++++++- .../evidence/contracts/bundle.schema.yaml | 2 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index 306ec30b2..a82591a97 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -1249,10 +1249,13 @@ impl SigningConfig { 31_536_000, "maximum assertion validity", )?; + // The same bound the relying party's `clockSkewSeconds` carries: an + // advertised skew a conformant verification policy cannot express would + // be unusable advice. Widening either one alone is a contract change. validate_range( self.verifier_clock_skew_seconds, 0, - 600, + 300, "verifier clock skew", ) } @@ -3909,6 +3912,68 @@ mod tests { )); } + #[test] + fn the_advertised_verifier_skew_stays_within_what_a_policy_may_express() { + // A deployment advertises `verifierClockSkewSeconds` so a relying party + // can adopt it, and a relying party expresses what it adopted as + // `clockSkewSeconds`. An advertised value no conformant policy can hold + // would be unusable advice, so the two bounds are one bound. Both are + // read from the contracts here rather than restated, so moving either + // one alone fails. + let bundle: serde_json::Value = serde_json::to_value( + serde_norway::from_slice::(include_bytes!( + "../../../products/evidence/contracts/bundle.schema.yaml" + )) + .expect("bundle contract is YAML"), + ) + .expect("bundle contract converts to JSON"); + let policy: serde_json::Value = serde_json::to_value( + serde_norway::from_slice::(include_bytes!( + "../../../products/evidence/contracts/verification-policy.schema.yaml" + )) + .expect("verification policy contract is YAML"), + ) + .expect("verification policy contract converts to JSON"); + let advertised = bundle["properties"]["signing"]["properties"]["verifierClockSkewSeconds"] + ["maximum"] + .as_u64() + .expect("the advertised skew declares an integer maximum"); + let expressible = policy["properties"]["clockSkewSeconds"]["maximum"] + .as_u64() + .expect("the expressible skew declares an integer maximum"); + assert_eq!( + advertised, expressible, + "a deployment may advertise a skew no conformant verification policy can express" + ); + + // Startup validation is the enforcement point, and it must agree with + // the contract rather than carry its own bound. + let yaml = include_str!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + ); + assert!(EvidenceConfig::parse_yaml(yaml.as_bytes()).is_ok()); + let validator = bundle_contract_validator(); + for (skew, accepted) in [(expressible, true), (expressible + 1, false)] { + let mutated = yaml.replace( + "verifierClockSkewSeconds: 30", + &format!("verifierClockSkewSeconds: {skew}"), + ); + assert_ne!(mutated, yaml, "{skew}"); + assert_eq!( + EvidenceConfig::parse_yaml(mutated.as_bytes()).is_ok(), + accepted, + "startup validation disagrees with the contract at {skew}" + ); + assert_eq!( + validator + .validate(&bundle_contract_instance(mutated.as_bytes())) + .is_ok(), + accepted, + "the bundle contract disagrees with startup validation at {skew}" + ); + } + } + #[test] fn response_formats_are_closed_unique_and_keep_signed_mandatory() { let yaml = include_str!( diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index 07076313f..dd2ccaa23 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -39,7 +39,7 @@ properties: items: {$ref: '#/$defs/public-jwk-path'} jwksPath: {const: /.well-known/evidence/jwks.json} maximumAssertionValiditySeconds: {type: integer, minimum: 1, maximum: 31536000} - verifierClockSkewSeconds: {type: integer, minimum: 0, maximum: 600} + verifierClockSkewSeconds: {type: integer, minimum: 0, maximum: 300} responseFormats: {$ref: '#/$defs/response-formats'} selectorProfiles: type: object From 8dda20fb9e2e2dabc67cf86df68f2cb62de9b4cb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 14:49:38 +0700 Subject: [PATCH 65/67] feat(evidence): read a raw response the same way in both bindings The Rust SDK's `RawEvidenceResponse` offers `body()` and `operation()` so a relying party can retain the exact bytes it verified and correlate a failed exchange with the deployment's audit trail. The Node binding wraps both as getters; the Python binding exposed neither, so the same response object was inspectable in one language and opaque in the other. A thin binding follows its core: divergence belongs in the core, not in one language surface. Python now exposes both as read-only attributes, the shape `VerifiedEvidence` already uses in this crate, with the committed stub updated to match. The existing drift test holds the stub and the compiled surface together in both directions, and new tests assert the body is exactly the served bytes, the operation is the correlation identifier the response carried (`None` when it carried none), and neither reading can be reassigned. Reading either one still judges nothing: `verify` remains the only thing that decides whether the bytes are trustworthy, and the class docstring says so. Signed-off-by: Jeremi Joslin --- .../registry_evidence_client/__init__.pyi | 10 ++- crates/registry-evidence-client-py/src/lib.rs | 26 +++++- .../tests/python/test_raw_response.py | 79 +++++++++++++++++++ 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 crates/registry-evidence-client-py/tests/python/test_raw_response.py diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi index 388e255a5..62188503c 100644 --- a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi @@ -106,11 +106,13 @@ class PreparedEvidenceRequest: subject_expectations: Union[str, Sequence[Any]] class RawEvidenceResponse: - """A signed response, read but not yet judged. No public constructor and - no attributes: obtain one only from `EvidenceClient.send`. Nothing in it - has been trusted yet; `verify` is what judges it.""" + """A signed response, read but not yet judged. No public constructor: + obtain one only from `EvidenceClient.send`. Reading either attribute + judges nothing; `verify` is what decides whether these bytes are + trustworthy.""" - ... + body: bytes + operation: Optional[str] class VerifiedEvidence: """A response that satisfied every expectation.""" diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs index f03de6994..b3c79f04d 100644 --- a/crates/registry-evidence-client-py/src/lib.rs +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -221,14 +221,34 @@ impl PreparedEvidenceRequest { /// A signed response, read but not yet judged. /// /// There is no constructor exposed to Python; the only way to obtain one is -/// [`EvidenceClient::send`]. It carries no attributes: nothing in it has been -/// trusted yet, and `verify` is what judges it, never Python code inspecting -/// its bytes directly. +/// [`EvidenceClient::send`]. Its two readings are the wrapped Rust type's own, +/// and reading either one judges nothing: `verify` is what decides whether +/// these bytes are trustworthy, never Python code inspecting them. #[pyclass(name = "RawEvidenceResponse", module = "registry_evidence_client")] struct RawEvidenceResponse { inner: RealRawEvidenceResponse, } +#[pymethods] +impl RawEvidenceResponse { + /// The exact bytes the deployment served. Retain them with the + /// transaction record: re-verifying later needs the bytes that were + /// verified, not a re-serialization of them. + #[getter] + fn body(&self) -> &[u8] { + self.inner.body() + } + + /// The deployment's opaque identifier for this exchange, if the response + /// carried one, for support correlation. Present here as well as on + /// `VerifiedEvidence`, so a response that fails verification can still be + /// reported against the deployment's own audit trail. + #[getter] + fn operation(&self) -> Option<&str> { + self.inner.operation() + } +} + /// A response that satisfied every expectation. /// /// Unlike the two classes above, this is a terminal result nothing hands back diff --git a/crates/registry-evidence-client-py/tests/python/test_raw_response.py b/crates/registry-evidence-client-py/tests/python/test_raw_response.py new file mode 100644 index 000000000..2f18df1f3 --- /dev/null +++ b/crates/registry-evidence-client-py/tests/python/test_raw_response.py @@ -0,0 +1,79 @@ +"""`RawEvidenceResponse` exposes the same two readings as the Rust SDK. + +`registry_evidence_client` is a thin binding over +`registry-evidence-client`, so its surface follows the core's: the core's +`RawEvidenceResponse` offers `body()` and `operation()` so a relying party can +retain the exact bytes it verified and correlate a request with the +deployment's audit trail. `registry-evidence-client-node` exposes both as +getters; Python exposes both as read-only attributes, the same shape +`VerifiedEvidence` already uses here. + +Reading either one still judges nothing: `verify()` is the only thing that +decides whether those bytes are trustworthy. +""" + +from __future__ import annotations + +import pathlib +import sys +import unittest + +_TESTS_DIR = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(_TESTS_DIR)) +sys.path.insert(0, str(_TESTS_DIR / "helpers")) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import fixtures # noqa: E402 +import registry_evidence_client as revc # noqa: E402 +from stub_server import StubRoute, StubServer # noqa: E402 + +EVIDENCE_JWS_MEDIA_TYPE = "application/jose+json" +# The correlation header the client reads, and a value that survives its +# sanitizer (non-empty, at most 64 bytes, ASCII alphanumeric only). +CORRELATION_HEADER = "x-request-id" +OPERATION = "01JQ0QZ8YHZ0000000000000AB" +# `send()` parses nothing, so the shape of these bytes does not matter here; +# what matters is that exactly they come back out. +SIGNED_BODY = b'{"payload": "not-a-real-jws", "signature": "not-a-real-signature"}' + + +class RawResponseTest(unittest.TestCase): + def _response(self, headers: dict[str, str]) -> revc.RawEvidenceResponse: + server = StubServer({}) + self.addCleanup(server.close) + server.routes["POST /v1/evidence"] = StubRoute( + status=200, + headers={"Content-Type": EVIDENCE_JWS_MEDIA_TYPE, **headers}, + body=SIGNED_BODY, + ) + client = revc.EvidenceClient(server.base_url, fixtures.VALID_JWKS, "test-token") + return client.send(client.prepare(fixtures.request_spec())) + + def test_the_body_is_exactly_the_bytes_the_deployment_served(self): + response = self._response({}) + self.assertIsInstance(response.body, bytes) + self.assertEqual(response.body, SIGNED_BODY) + + def test_the_operation_is_the_correlation_identifier_the_response_carried(self): + response = self._response({CORRELATION_HEADER: OPERATION}) + self.assertEqual(response.operation, OPERATION) + + def test_a_response_without_a_correlation_identifier_reports_none(self): + self.assertIsNone(self._response({}).operation) + + def test_neither_reading_can_be_reassigned(self): + # Both are readings of what arrived over the wire. Letting Python + # overwrite either one would let a later `verify()` failure be + # reported against bytes or an operation the deployment never sent. + response = self._response({CORRELATION_HEADER: OPERATION}) + for attribute, value in (("body", b"tampered"), ("operation", "tampered")): + with self.subTest(attribute=attribute): + with self.assertRaises(AttributeError): + setattr(response, attribute, value) + + +if __name__ == "__main__": + unittest.main() From cb98459e2c8e235bacae3b0bd189128183449a36 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 14:56:32 +0700 Subject: [PATCH 66/67] fix(evidence): make the source-neutrality gate able to fail The gate searched with ripgrep, which the hosted runner does not have. An absent command exits 127, and `if rg ...; then fail; fi` reads any non-zero status as "found nothing", so every run on that runner reported a clean tree after printing four `rg: not found` lines and reading nothing. The same construct also swallowed a path that had been renamed away from under it. It now searches with `grep -E`, the way its sibling check-verifier-portability.sh already does for exactly this reason, and treats every status above 1 as a broken check rather than a clean tree. Each path it names explicitly must exist, each root it sweeps must exist, and both an empty source enumeration and empty masked text are failures, so the gate can no longer pass by searching nothing. Because its subject is text rather than a program's behavior, nothing else in the tree proved it could still fail. A self-test now builds a sandbox tree in the shape the gate expects and plants one violation at a time: 18 cases cover each of the three sweeps, the test-only exemptions that must keep passing (`#[cfg(test)]` items and `*_tests.rs` files), the package-manifest exclusion that keeps an SPDX license field from matching the licence pattern, a named file and a named root that disappeared, both empty-enumeration cases, and a clean tree and a planted violation under a PATH holding only the system directories, which is where the ripgrep regression would have surfaced. CI runs the self-test immediately before the gate. The gate's header comment also described the masking as exempting comments and string literals, which it never did: masking exists only to locate the braces that close a `#[cfg(test)]` item, and the emitted text keeps comments and literals. The comment now says what the code does, and the self-test pins both behaviors. Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 3 + .../scripts/check-source-neutrality.sh | 207 +++++++++---- .../scripts/test-check-source-neutrality.sh | 271 ++++++++++++++++++ 3 files changed, 423 insertions(+), 58 deletions(-) create mode 100755 products/evidence/scripts/test-check-source-neutrality.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfe40237d..6f8f9a538 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -458,6 +458,9 @@ jobs: - name: Reproduce Evidence generated contracts run: products/evidence/scripts/check-contracts.sh + - name: Self-test the Evidence neutrality gate + run: products/evidence/scripts/test-check-source-neutrality.sh + - name: Enforce Evidence source-product neutrality run: products/evidence/scripts/check-source-neutrality.sh diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index 6058b648c..c4704748d 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -1,25 +1,111 @@ -#!/bin/sh -set -eu +#!/usr/bin/env bash +set -euo pipefail -repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +# Evidence production code, adopter tooling, the shipped binding surface, and the +# public configuration stay free of source-product names and of acceptance-case +# or jurisdiction-specific vocabulary. DHIS2 and OpenCRVS names, and the words +# each acceptance definition happens to use, are test-only. +# +# A source-product name may still appear where it belongs: in test-only code. +# `#[cfg(test)]` items are cut out of the Rust text before it is searched, and +# `*_tests.rs` files are skipped entirely. Everything else in a production source +# file is searched, comments and string literals included. The inline masking +# below is how the cut is made reliable, not an exemption: it blanks comments and +# literals only to find the braces that really close a `#[cfg(test)]` item, +# then emits the unmasked text between those spans. +# +# `grep -E` rather than ripgrep, for the reason its sibling +# `check-verifier-portability.sh` states: the hosted runner that gates this has +# no ripgrep, and an absent search command exits 127, which reads as "found +# nothing" in any construct that only distinguishes match from no match. Every +# path this gate names is checked to exist and every search status above 1 is a +# failure, because a gate that cannot fail is not a gate. + +CDPATH='' +repository_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd) temporary_root=$(mktemp -d) trap 'rm -rf "$temporary_root"' EXIT HUP INT TERM production_text="$temporary_root/production-rust.txt" -: >"$production_text" -for source_file in $( - rg --files \ - "$repository_root/crates/registry-evidence/src" \ - "$repository_root/crates/registry-evidence-client/src" \ - "$repository_root/crates/registry-evidence-client-node/src" \ - "$repository_root/crates/registry-evidence-client-py/src" \ - "$repository_root/crates/registry-evidence-verifier/src" \ - "$repository_root/crates/registry-evidencectl/src" \ - -g '*.rs' | sort -); do +source_roots=( + "$repository_root/crates/registry-evidence/src" + "$repository_root/crates/registry-evidence-client/src" + "$repository_root/crates/registry-evidence-client-node/src" + "$repository_root/crates/registry-evidence-client-py/src" + "$repository_root/crates/registry-evidence-verifier/src" + "$repository_root/crates/registry-evidencectl/src" +) + +# The two bindings ship a non-Rust surface that the Rust sweep cannot see. +# Enumerate exactly those shipped files: a sweep of the binding crate +# directories would also reach their tests, fixtures, and installed +# dependencies, where a source-product name is allowed. +shipped_binding_surface=( + "$repository_root/crates/registry-evidence-client-node/client.js" + "$repository_root/crates/registry-evidence-client-node/client.d.ts" + "$repository_root/crates/registry-evidence-client-node/index.js" + "$repository_root/crates/registry-evidence-client-node/index.d.ts" + "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py" + "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi" +) + +# The two package manifests carry no caller-visible API surface that could name +# an acceptance case, and their SPDX license field matches the licence pattern, +# so they take the source-product sweep only. +shipped_package_manifests=( + "$repository_root/crates/registry-evidence-client-node/package.json" + "$repository_root/crates/registry-evidence-client-py/pyproject.toml" +) + +cargo_manifests=( + "$repository_root/crates/registry-evidence/Cargo.toml" + "$repository_root/crates/registry-evidence-client/Cargo.toml" + "$repository_root/crates/registry-evidence-client-node/Cargo.toml" + "$repository_root/crates/registry-evidence-client-py/Cargo.toml" + "$repository_root/crates/registry-evidence-verifier/Cargo.toml" + "$repository_root/crates/registry-evidencectl/Cargo.toml" + "$repository_root/Cargo.toml" +) + +published_roots=( + "$repository_root/products/evidence/generated" + "$repository_root/products/evidence/contracts" +) + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +# A path this gate names explicitly, that a rename or a move has taken away, +# silently narrows what is searched. Say so instead. +for named_file in \ + "${shipped_binding_surface[@]}" \ + "${shipped_package_manifests[@]}" \ + "${cargo_manifests[@]}"; do + [[ -f "$named_file" ]] || + fail "This gate names $named_file, which no longer exists: update the list it appears in." +done + +for named_root in "${source_roots[@]}" "${published_roots[@]}"; do + [[ -d "$named_root" ]] || + fail "This gate sweeps $named_root, which no longer exists: update the list it appears in." +done + +production_sources=() +while IFS= read -r source_file; do case "$source_file" in - *_tests.rs) continue ;; + *_tests.rs) continue ;; esac + production_sources+=("$source_file") +done < <(find "${source_roots[@]}" -type f -name '*.rs' | sort) + +if [[ "${#production_sources[@]}" -eq 0 ]]; then + fail 'The neutrality sweep found no Evidence production Rust to search.' +fi + +: >"$production_text" +for source_file in "${production_sources[@]}"; do python3 - "$source_file" >>"$production_text" <<'PY' import re import sys @@ -134,51 +220,56 @@ sys.stdout.write(source[cursor:]) PY done -# The two bindings ship a non-Rust surface that the Rust sweep above cannot see. -# Enumerate exactly those shipped files: a sweep of the binding crate directories -# would also reach their tests, fixtures, and installed dependencies, where a -# source-product name is allowed. -if rg -n -i 'dhis2|opencrvs' \ - "$production_text" \ - "$repository_root/crates/registry-evidence-client-node/client.js" \ - "$repository_root/crates/registry-evidence-client-node/client.d.ts" \ - "$repository_root/crates/registry-evidence-client-node/index.js" \ - "$repository_root/crates/registry-evidence-client-node/index.d.ts" \ - "$repository_root/crates/registry-evidence-client-node/package.json" \ - "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py" \ - "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi" \ - "$repository_root/crates/registry-evidence-client-py/pyproject.toml" \ - "$repository_root/crates/registry-evidence/Cargo.toml" \ - "$repository_root/crates/registry-evidence-client/Cargo.toml" \ - "$repository_root/crates/registry-evidence-client-node/Cargo.toml" \ - "$repository_root/crates/registry-evidence-client-py/Cargo.toml" \ - "$repository_root/crates/registry-evidence-verifier/Cargo.toml" \ - "$repository_root/crates/registry-evidencectl/Cargo.toml" \ - "$repository_root/Cargo.toml"; then - echo 'Evidence production code, adopter tooling, the shipped binding surface, or Cargo metadata contains a prohibited source-product name.' >&2 - exit 1 +if [[ ! -s "$production_text" ]]; then + fail 'Masking left no Evidence production Rust text to search.' fi -# The two package manifests stay out of this sweep: their SPDX license field -# matches the licence pattern, and neither declares a caller-visible API surface -# that could name an acceptance case. -if rg -n -i 'adult|age[_ -]?at|residence|licen[cs]e|parentage|legal[_ -]?parent|given_name|family_name|birth_date|national[_ -]?identifier' \ - "$production_text" \ - "$repository_root/crates/registry-evidence-client-node/client.js" \ - "$repository_root/crates/registry-evidence-client-node/client.d.ts" \ - "$repository_root/crates/registry-evidence-client-node/index.js" \ - "$repository_root/crates/registry-evidence-client-node/index.d.ts" \ - "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.py" \ - "$repository_root/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi"; then - echo 'Evidence production Rust, adopter tooling, or the shipped binding surface contains acceptance-case or jurisdiction-specific vocabulary.' >&2 - exit 1 -fi +published_files=() +while IFS= read -r published_file; do + published_files+=("$published_file") +done < <(find "${published_roots[@]}" -type f | sort) -generated_root="$repository_root/products/evidence/generated" -contract_root="$repository_root/products/evidence/contracts" -if rg -n -i 'dhis2|opencrvs' "$generated_root" "$contract_root"; then - echo 'Evidence public configuration or generated contracts contain a prohibited source-product name.' >&2 - exit 1 +if [[ "${#published_files[@]}" -eq 0 ]]; then + fail 'The neutrality sweep found no Evidence public configuration or generated contracts to search.' fi -echo 'Evidence source-product and domain neutrality checks passed.' +# grep reports 0 for a match, 1 for none, and above 1 for its own failure. The +# third outcome is a broken check rather than a clean tree, so it fails here. +# `/dev/null` keeps the file name on every reported line, whatever the list size. +sweep() { + local message=$1 pattern=$2 + shift 2 + local status=0 matches + matches=$(grep -n -i -E -e "$pattern" /dev/null "$@") || status=$? + case "$status" in + 0) + printf '%s\n' "$matches" >&2 + fail "$message" + ;; + 1) ;; + *) + fail "A neutrality sweep failed with status $status, searching for: $pattern" + ;; + esac +} + +sweep \ + 'Evidence production code, adopter tooling, the shipped binding surface, or Cargo metadata contains a prohibited source-product name.' \ + 'dhis2|opencrvs' \ + "$production_text" \ + "${shipped_binding_surface[@]}" \ + "${shipped_package_manifests[@]}" \ + "${cargo_manifests[@]}" + +sweep \ + 'Evidence production Rust, adopter tooling, or the shipped binding surface contains acceptance-case or jurisdiction-specific vocabulary.' \ + 'adult|age[_ -]?at|residence|licen[cs]e|parentage|legal[_ -]?parent|given_name|family_name|birth_date|national[_ -]?identifier' \ + "$production_text" \ + "${shipped_binding_surface[@]}" + +sweep \ + 'Evidence public configuration or generated contracts contain a prohibited source-product name.' \ + 'dhis2|opencrvs' \ + "${published_files[@]}" + +printf 'Evidence source-product and domain neutrality checks passed.\n' diff --git a/products/evidence/scripts/test-check-source-neutrality.sh b/products/evidence/scripts/test-check-source-neutrality.sh new file mode 100755 index 000000000..55a17b94c --- /dev/null +++ b/products/evidence/scripts/test-check-source-neutrality.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +set -euo pipefail + +# `check-source-neutrality.sh` is the only Evidence gate whose subject is text +# rather than a program's behavior, so nothing else in the tree proves it can +# still fail. It once could not: an earlier version searched with ripgrep, which +# the hosted runner does not have, so every run reported a clean tree without +# reading a byte of it. +# +# This exercises the gate against a sandbox tree it can be pointed at, in the +# shape it expects, planting one violation at a time. Each case asserts an +# outcome the gate must produce, and the sandbox is thrown away afterwards, so +# the real tree is never modified. + +CDPATH='' +scripts_directory=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +gate_under_test="$scripts_directory/check-source-neutrality.sh" +sandbox_root=$(mktemp -d) +trap 'rm -rf "$sandbox_root"' EXIT HUP INT TERM + +failures=0 + +# The sandbox mirrors only what the gate reads: the six production source roots, +# the shipped binding surface, the Cargo and package manifests, and the published +# configuration. `pristine` is rebuilt for every case, so no case can see +# another's planting. +build_pristine_tree() { + local root="$sandbox_root/pristine" + rm -rf "$root" + mkdir -p \ + "$root/products/evidence/scripts" \ + "$root/products/evidence/generated" \ + "$root/products/evidence/contracts" \ + "$root/crates/registry-evidence/src" \ + "$root/crates/registry-evidence-client/src" \ + "$root/crates/registry-evidence-client-node/src" \ + "$root/crates/registry-evidence-client-py/src" \ + "$root/crates/registry-evidence-verifier/src" \ + "$root/crates/registry-evidencectl/src" \ + "$root/crates/registry-evidence-client-py/python/registry_evidence_client" + + cp "$gate_under_test" "$root/products/evidence/scripts/check-source-neutrality.sh" + + local crate + for crate in \ + registry-evidence \ + registry-evidence-client \ + registry-evidence-client-node \ + registry-evidence-client-py \ + registry-evidence-verifier \ + registry-evidencectl; do + printf 'pub fn evaluate() -> bool {\n true\n}\n' >"$root/crates/$crate/src/lib.rs" + printf '[package]\nname = "%s"\nlicense = "Apache-2.0"\n' "$crate" >"$root/crates/$crate/Cargo.toml" + done + printf '[workspace]\nmembers = ["crates/*"]\n' >"$root/Cargo.toml" + + local node_root="$root/crates/registry-evidence-client-node" + printf 'module.exports = {};\n' >"$node_root/client.js" + printf 'export declare class EvidenceClient {}\n' >"$node_root/client.d.ts" + printf 'module.exports = require("./client.js");\n' >"$node_root/index.js" + printf 'export * from "./client";\n' >"$node_root/index.d.ts" + # A real package manifest carries an SPDX license field, which matches the + # acceptance-vocabulary pattern's `licen[cs]e` alternative. The gate excludes + # the two package manifests from that sweep for exactly this reason, so the + # sandbox keeps the field that makes the exclusion load-bearing. + printf '{\n "name": "@registrystack/evidence-client",\n "license": "Apache-2.0"\n}\n' \ + >"$node_root/package.json" + + local python_root="$root/crates/registry-evidence-client-py" + printf '[project]\nname = "registry-evidence-client"\nlicense = "Apache-2.0"\n' \ + >"$python_root/pyproject.toml" + printf 'from .registry_evidence_client import EvidenceClient\n' \ + >"$python_root/python/registry_evidence_client/__init__.py" + printf 'class EvidenceClient: ...\n' \ + >"$python_root/python/registry_evidence_client/__init__.pyi" + + printf '{\n "requirements": []\n}\n' >"$root/products/evidence/generated/requirements.json" + printf 'type: object\n' >"$root/products/evidence/contracts/bundle.schema.yaml" +} + +# Copy the pristine tree, hand it to the case body to plant in, and run the gate. +run_case() { + local name=$1 expectation=$2 plant=$3 + build_pristine_tree + local root="$sandbox_root/case" + rm -rf "$root" + cp -R "$sandbox_root/pristine" "$root" + "$plant" "$root" + + local status=0 output + output=$("$root/products/evidence/scripts/check-source-neutrality.sh" 2>&1) || status=$? + + case "$expectation" in + pass) + if [[ "$status" -ne 0 ]]; then + printf 'FAIL %s: expected the gate to pass, got status %s:\n%s\n' \ + "$name" "$status" "$output" >&2 + failures=$((failures + 1)) + return + fi + if [[ "$output" != *'neutrality checks passed'* ]]; then + printf 'FAIL %s: the gate passed without reporting that it did:\n%s\n' \ + "$name" "$output" >&2 + failures=$((failures + 1)) + return + fi + ;; + fail) + if [[ "$status" -eq 0 ]]; then + printf 'FAIL %s: expected the gate to fail, it passed:\n%s\n' "$name" "$output" >&2 + failures=$((failures + 1)) + return + fi + ;; + *) + printf 'FAIL %s: unknown expectation %s\n' "$name" "$expectation" >&2 + failures=$((failures + 1)) + return + ;; + esac + printf 'ok %s\n' "$name" +} + +plant_nothing() { :; } + +plant_source_product_in_production_code() { + printf 'pub fn dhis2_tracked_entity() -> bool {\n true\n}\n' \ + >>"$1/crates/registry-evidence/src/lib.rs" +} + +plant_source_product_in_a_comment() { + printf '// Proven against a sanitized DHIS2 mock in the tests.\n' \ + >>"$1/crates/registry-evidence/src/lib.rs" +} + +plant_source_product_in_a_string_literal() { + printf 'pub const MOCK: &str = "dhis2 mock";\n' \ + >>"$1/crates/registry-evidence/src/lib.rs" +} + +plant_source_product_in_a_test_module() { + cat >>"$1/crates/registry-evidence/src/lib.rs" <<'RUST' +#[cfg(test)] +mod tests { + fn opencrvs_record() -> bool { + true + } +} +RUST +} + +plant_source_product_in_a_tests_file() { + printf 'fn dhis2_fixture() -> bool {\n true\n}\n' \ + >"$1/crates/registry-evidence/src/source_tests.rs" +} + +plant_acceptance_vocabulary_in_production_code() { + printf 'pub fn adult_status() -> bool {\n true\n}\n' \ + >>"$1/crates/registry-evidence-verifier/src/lib.rs" +} + +plant_acceptance_vocabulary_in_the_binding_surface() { + printf 'export declare function legalParentOf(subject: string): string;\n' \ + >>"$1/crates/registry-evidence-client-node/index.d.ts" +} + +plant_source_product_in_the_binding_surface() { + printf 'export declare const DHIS2_BASE_URL: string;\n' \ + >>"$1/crates/registry-evidence-client-node/index.d.ts" +} + +plant_source_product_in_a_cargo_manifest() { + printf 'dhis2-connector = "1"\n' >>"$1/crates/registry-evidence/Cargo.toml" +} + +plant_source_product_in_generated_configuration() { + printf '{\n "source": "opencrvs"\n}\n' >"$1/products/evidence/generated/source.json" +} + +plant_source_product_in_a_contract() { + printf 'title: dhis2 bundle\n' >>"$1/products/evidence/contracts/bundle.schema.yaml" +} + +remove_a_named_binding_file() { + rm "$1/crates/registry-evidence-client-node/index.d.ts" +} + +remove_a_named_source_root() { + rm -rf "$1/crates/registry-evidencectl/src" +} + +empty_every_source_root() { + find "$1/crates" -name '*.rs' -delete +} + +empty_the_published_configuration() { + rm -f "$1/products/evidence/generated/requirements.json" \ + "$1/products/evidence/contracts/bundle.schema.yaml" +} + +run_case 'a clean tree passes' pass plant_nothing +run_case 'a source-product name in production code fails' \ + fail plant_source_product_in_production_code +# Only test-only code is exempt. A comment or a literal in a production source +# file is searched like the code around it, so these two are failures, and the +# `#[cfg(test)]` and `_tests.rs` cases below are where a name is allowed. +run_case 'a source-product name in a production comment fails' \ + fail plant_source_product_in_a_comment +run_case 'a source-product name in a production string literal fails' \ + fail plant_source_product_in_a_string_literal +run_case 'a source-product name in a #[cfg(test)] module passes' \ + pass plant_source_product_in_a_test_module +run_case 'a source-product name in a _tests.rs file passes' \ + pass plant_source_product_in_a_tests_file +run_case 'acceptance vocabulary in production code fails' \ + fail plant_acceptance_vocabulary_in_production_code +run_case 'acceptance vocabulary in the shipped binding surface fails' \ + fail plant_acceptance_vocabulary_in_the_binding_surface +run_case 'a source-product name in the shipped binding surface fails' \ + fail plant_source_product_in_the_binding_surface +run_case 'a source-product name in Cargo metadata fails' \ + fail plant_source_product_in_a_cargo_manifest +run_case 'a source-product name in generated configuration fails' \ + fail plant_source_product_in_generated_configuration +run_case 'a source-product name in a published contract fails' \ + fail plant_source_product_in_a_contract +run_case 'a named binding file that no longer exists fails' \ + fail remove_a_named_binding_file +run_case 'a named source root that no longer exists fails' \ + fail remove_a_named_source_root +run_case 'a tree with no production Rust left to search fails' \ + fail empty_every_source_root +run_case 'a tree with no published configuration left to search fails' \ + fail empty_the_published_configuration + +# The gate must not depend on any tool the hosted runner lacks. Re-run the clean +# tree with a PATH holding only the system directories, which is where the +# ripgrep regression would have surfaced. +build_pristine_tree +status=0 +output=$(PATH='/usr/bin:/bin' "$sandbox_root/pristine/products/evidence/scripts/check-source-neutrality.sh" 2>&1) || + status=$? +if [[ "$status" -eq 0 && "$output" == *'neutrality checks passed'* ]]; then + printf 'ok a clean tree passes with only the system PATH\n' +else + printf 'FAIL a clean tree passes with only the system PATH: status %s:\n%s\n' \ + "$status" "$output" >&2 + failures=$((failures + 1)) +fi + +# And it must still fail there: a gate that passes only because its search tool +# is missing is the defect this file exists to catch. +build_pristine_tree +plant_source_product_in_production_code "$sandbox_root/pristine" +status=0 +output=$(PATH='/usr/bin:/bin' "$sandbox_root/pristine/products/evidence/scripts/check-source-neutrality.sh" 2>&1) || + status=$? +if [[ "$status" -ne 0 ]]; then + printf 'ok a violation still fails with only the system PATH\n' +else + printf 'FAIL a violation still fails with only the system PATH: the gate passed:\n%s\n' \ + "$output" >&2 + failures=$((failures + 1)) +fi + +if [[ "$failures" -ne 0 ]]; then + printf '%s neutrality gate case(s) failed.\n' "$failures" >&2 + exit 1 +fi + +printf 'The Evidence neutrality gate reports every planted violation.\n' From c201302ad1d4a7d8d8ffc32c22fbbfec20937fbb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 15:11:04 +0700 Subject: [PATCH 67/67] fix(evidence): bound the client fixture policy by its own contract The two binding crates' committed `policy.json` named a ten-year `maximumAssertionLifetimeSeconds`, where the frozen verification-policy contract bounds that field at one year. The fixture therefore modelled a relying party's policy no conformant relying party could have written, and nothing in either crate noticed. The fixture window is now thirty days, and a new test in each crate validates the committed document against `products/evidence/contracts/verification-policy.schema.yaml`, so the fixture cannot drift outside the contract again. The long window existed to keep the committed response verifiable against the wall clock indefinitely; the checks that read the committed response now verify at a pinned instant one day past the signing instant instead, and the real-clock path through `into_policy` and `verify_flattened_jws` is covered by signing fresh evidence in-memory. Only `tests/golden_fixture.rs` in each crate and the Python crate's stale-fixture negative case read the response and policy fixtures; the JS and Python suites read only `jwks.json`, so no language-level test depends on the window. The Python stale-fixture case now verifies through `verify_as_of` at that same pinned instant and asserts the failure class is `policy`. Against the wall clock it would have started failing for expiry once the shorter window elapsed, while still passing, and the reason it exists to prove (a response signed for a different nonce is refused) would have stopped being tested. All six fixture files are regenerated with their documented generator commands, never by hand; regeneration mints a fresh key, so `jwks.json` and `response.jws.json` change in both crates. Security review notes: no production code changes. The bound moves in the restrictive direction only, from a value the contract forbids to one it allows, so no policy that verified before is newly rejected. The added negative-case assertion pins the generic `policy` class rather than any field-level detail, so re-verification stays a non-oracle for which hidden comparison failed. No credential, token, live response, or demo-subject identifier is committed: the fixture keys are generated per run and only their public halves are stored. Related gap, not addressed here and out of this change's scope: nothing enforces the contract's `maximumAssertionLifetimeSeconds` bound when an `EvidenceVerificationPolicyDocument` is deserialized, so a relying party can still express in code a policy the contract forbids and the verifier will honour it. That is the defect this fixture was a symptom of. Signed-off-by: Jeremi Joslin --- Cargo.lock | 4 + .../registry-evidence-client-node/Cargo.toml | 5 + .../tests/fixtures/jwks.json | 2 +- .../tests/fixtures/policy.json | 2 +- .../tests/fixtures/response.jws.json | 4 +- .../tests/golden_fixture.rs | 161 ++++++++++++++---- crates/registry-evidence-client-py/Cargo.toml | 5 + .../tests/fixtures/jwks.json | 2 +- .../tests/fixtures/policy.json | 2 +- .../tests/fixtures/response.jws.json | 4 +- .../tests/golden_fixture.rs | 161 ++++++++++++++---- .../tests/happy_path.rs | 31 +++- 12 files changed, 301 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5d2130d8..90497b20e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5632,6 +5632,7 @@ dependencies = [ "chrono", "ed25519-dalek", "getrandom 0.4.3", + "jsonschema 0.18.3", "napi", "napi-build", "napi-derive", @@ -5640,6 +5641,7 @@ dependencies = [ "registry-platform-crypto", "serde", "serde_json", + "serde_norway", "tokio", "url", ] @@ -5652,6 +5654,7 @@ dependencies = [ "chrono", "ed25519-dalek", "getrandom 0.4.3", + "jsonschema 0.18.3", "pyo3", "pyo3-build-config", "registry-evidence-client", @@ -5659,6 +5662,7 @@ dependencies = [ "registry-platform-crypto", "serde", "serde_json", + "serde_norway", "tokio", "url", "wiremock", diff --git a/crates/registry-evidence-client-node/Cargo.toml b/crates/registry-evidence-client-node/Cargo.toml index 3eb62362b..be6e74098 100644 --- a/crates/registry-evidence-client-node/Cargo.toml +++ b/crates/registry-evidence-client-node/Cargo.toml @@ -35,5 +35,10 @@ napi-build.workspace = true base64.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true +# The committed policy fixture stands for a relying party's own verification +# policy document, so the tests validate it against the frozen contract that +# governs one. `jsonschema` compiles that contract and `serde_norway` reads it. +jsonschema.workspace = true registry-evidence-verifier.workspace = true serde.workspace = true +serde_norway.workspace = true diff --git a/crates/registry-evidence-client-node/tests/fixtures/jwks.json b/crates/registry-evidence-client-node/tests/fixtures/jwks.json index 202b59fc8..a2bf16137 100644 --- a/crates/registry-evidence-client-node/tests/fixtures/jwks.json +++ b/crates/registry-evidence-client-node/tests/fixtures/jwks.json @@ -5,7 +5,7 @@ "crv": "Ed25519", "kid": "evidence-node-fixture-key-1", "kty": "OKP", - "x": "jntG5oqYpjSgzgWRRFfA_jv6LrTWTu15HAXzM99IVIo" + "x": "34jRMEq83DuDYLQbJLPH52qmMl1WWM54R6sDokOThsc" } ] } diff --git a/crates/registry-evidence-client-node/tests/fixtures/policy.json b/crates/registry-evidence-client-node/tests/fixtures/policy.json index c16615da0..62e8f3321 100644 --- a/crates/registry-evidence-client-node/tests/fixtures/policy.json +++ b/crates/registry-evidence-client-node/tests/fixtures/policy.json @@ -20,6 +20,6 @@ "form": "boolean" } ], - "maximumAssertionLifetimeSeconds": 315360000, + "maximumAssertionLifetimeSeconds": 2592000, "clockSkewSeconds": 30 } diff --git a/crates/registry-evidence-client-node/tests/fixtures/response.jws.json b/crates/registry-evidence-client-node/tests/fixtures/response.jws.json index 03421dd92..be7b94f1a 100644 --- a/crates/registry-evidence-client-node/tests/fixtures/response.jws.json +++ b/crates/registry-evidence-client-node/tests/fixtures/response.jws.json @@ -1,5 +1,5 @@ { "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2aWRlbmNlLW5vZGUtZml4dHVyZS1rZXktMSIsInR5cCI6ImV2aWRlbmNlK2p3cyIsImN0eSI6ImFwcGxpY2F0aW9uL2V2aWRlbmNlK2pzb24ifQ", - "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpub2RlLWZpeHR1cmUiLCJ0eXBlIjoiRXZpZGVuY2UiLCJzdXBwb3J0c1JlcXVpcmVtZW50IjoidXJuOmV4YW1wbGU6cmVxdWlyZW1lbnQ6djEiLCJpc0NvbmZvcm1hbnRUbyI6InVybjpleGFtcGxlOmV2aWRlbmNlLXR5cGU6djEiLCJpc3N1ZWRCeSI6InVybjpleGFtcGxlOmlzc3VlciIsInByb3ZpZGVkQnkiOiJ1cm46ZXhhbXBsZTpwcm92aWRlciIsImlzc3VlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJvYnNlcnZlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJ2YWxpZFVudGlsIjoiMjAzNi0wNy0yOVQwMDowMDowMFoiLCJwdXJwb3NlIjoiZXhhbXBsZS1wdXJwb3NlIiwiYXVkaWVuY2UiOiJ1cm46ZXhhbXBsZTphdWRpZW5jZSIsImNvbmZpZ3VyYXRpb25SZXZpc2lvbiI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic3ViamVjdHMiOlt7InJvbGUiOiJzdWJqZWN0IiwiYmluZGluZyI6InVybjpldmlkZW5jZTpzdWJqZWN0OnYxX0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEifV0sInN1cHBvcnRlZFZhbHVlcyI6W3sicHJvdmlkZXNWYWx1ZUZvciI6InVybjpleGFtcGxlOmNvbmNlcHQ6c3RhdHVzLWhvbGRzIiwidmFsdWUiOnRydWV9XX0", - "signature": "osWWg3suF31FSjdag6LtBdGs8E9RO47XOhAqbtUhF9ZugYk8yh9WuZkRNcLo_k9A5ONmwy-aT9eA8ept8JmdCw" + "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpub2RlLWZpeHR1cmUiLCJ0eXBlIjoiRXZpZGVuY2UiLCJzdXBwb3J0c1JlcXVpcmVtZW50IjoidXJuOmV4YW1wbGU6cmVxdWlyZW1lbnQ6djEiLCJpc0NvbmZvcm1hbnRUbyI6InVybjpleGFtcGxlOmV2aWRlbmNlLXR5cGU6djEiLCJpc3N1ZWRCeSI6InVybjpleGFtcGxlOmlzc3VlciIsInByb3ZpZGVkQnkiOiJ1cm46ZXhhbXBsZTpwcm92aWRlciIsImlzc3VlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJvYnNlcnZlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJ2YWxpZFVudGlsIjoiMjAyNi0wOC0zMVQwMDowMDowMFoiLCJwdXJwb3NlIjoiZXhhbXBsZS1wdXJwb3NlIiwiYXVkaWVuY2UiOiJ1cm46ZXhhbXBsZTphdWRpZW5jZSIsImNvbmZpZ3VyYXRpb25SZXZpc2lvbiI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic3ViamVjdHMiOlt7InJvbGUiOiJzdWJqZWN0IiwiYmluZGluZyI6InVybjpldmlkZW5jZTpzdWJqZWN0OnYxX0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEifV0sInN1cHBvcnRlZFZhbHVlcyI6W3sicHJvdmlkZXNWYWx1ZUZvciI6InVybjpleGFtcGxlOmNvbmNlcHQ6c3RhdHVzLWhvbGRzIiwidmFsdWUiOnRydWV9XX0", + "signature": "8-ktnYTjflZ6Ctw35M6bfySjhG_J48joahbJeWOb73GkB58vfl2wFmTJDuVsu0qxblNhEbJoC9cESE2HCEy6Ag" } diff --git a/crates/registry-evidence-client-node/tests/golden_fixture.rs b/crates/registry-evidence-client-node/tests/golden_fixture.rs index 883e600f0..30de59189 100644 --- a/crates/registry-evidence-client-node/tests/golden_fixture.rs +++ b/crates/registry-evidence-client-node/tests/golden_fixture.rs @@ -37,16 +37,67 @@ const FIXTURE_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const ACTIVE_KEY_ID: &str = "evidence-node-fixture-key-1"; -/// Ten years past `issued_at`. The JS suite runs this fixture through -/// `discover`/`fetchJwks` stubs indefinitely into the future, so its validity -/// window has to outlive ordinary gaps between regenerations, not just the day -/// it was generated. -const FIXTURE_LIFETIME_DAYS: i64 = 3650; +/// The instant the committed response is signed for, shared by the generator +/// and by every check that reads the result, so nothing has to re-derive it +/// from the committed bytes. +const FIXTURE_ISSUED_AT: &str = "2026-08-01T00:00:00Z"; + +/// Thirty days past `FIXTURE_ISSUED_AT`, which is also the acceptance ceiling +/// the committed policy states. It has to stay inside the +/// `maximumAssertionLifetimeSeconds` bound the verification-policy contract +/// sets, or the fixture would model a policy no conformant relying party could +/// express. Nothing here needs a longer window: the checks that read the +/// committed response verify at a pinned instant rather than at the wall clock, +/// and the real-clock path is covered by signing fresh evidence instead. +const FIXTURE_LIFETIME_DAYS: i64 = 30; fn fixtures_dir() -> &'static Path { Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) } +fn fixture_issued_at() -> DateTime { + FIXTURE_ISSUED_AT + .parse() + .expect("the fixture instant parses") +} + +/// A fresh Ed25519 signer under the fixture's key id. The private half never +/// leaves the process that made it: regeneration commits only the public key, +/// and the real-clock check below discards the whole pair when it returns. +fn fixture_signer() -> LocalJwkSigner { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the host supplies randomness"); + let signing_key = SigningKey::from_bytes(&seed); + let private_jwk_json = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": ACTIVE_KEY_ID, + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + }); + let private_jwk = + PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); + LocalJwkSigner::new(private_jwk).expect("the generated key signs") +} + +fn public_jwks(signer: &LocalJwkSigner) -> JwksDocument { + JwksDocument { + keys: vec![serde_json::to_value(signer.public_jwk()).expect("the public key serializes")], + } +} + +fn assert_fixture_shape(evidence: &Evidence) { + assert_eq!(evidence.request_nonce, FIXTURE_NONCE); + assert_eq!(evidence.subjects.len(), 1); + assert_eq!(evidence.subjects[0].role, "subject"); + assert_eq!(evidence.supported_values.len(), 1); + assert!(matches!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + )); +} + fn fixture_evidence(issued_at: DateTime, valid_until: DateTime) -> Evidence { Evidence { schema: EVIDENCE_SCHEMA_V1.to_owned(), @@ -152,29 +203,14 @@ fn write_pretty(path: &Path, value: &T) { #[tokio::test] #[ignore] async fn regenerate_golden_fixture() { - let mut seed = [0_u8; 32]; - getrandom::fill(&mut seed).expect("the host supplies randomness"); - let signing_key = SigningKey::from_bytes(&seed); - let private_jwk_json = serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": ACTIVE_KEY_ID, - "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), - "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - }); - let private_jwk = - PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); - let signer = LocalJwkSigner::new(private_jwk).expect("the generated key signs"); + let signer = fixture_signer(); - let issued_at: DateTime = "2026-08-01T00:00:00Z".parse().expect("issued_at parses"); + let issued_at = fixture_issued_at(); let valid_until = issued_at + ChronoDuration::days(FIXTURE_LIFETIME_DAYS); let evidence = fixture_evidence(issued_at, valid_until); let policy_document = fixture_policy_document(&evidence); let jws = sign(&evidence, &signer).await; - let jwks = JwksDocument { - keys: vec![serde_json::to_value(signer.public_jwk()).expect("the public key serializes")], - }; + let jwks = public_jwks(&signer); let dir = fixtures_dir(); fs::create_dir_all(dir).expect("the fixtures directory can be created"); @@ -183,11 +219,50 @@ async fn regenerate_golden_fixture() { write_pretty(&dir.join("policy.json"), &policy_document); } -/// Confirms the committed fixture still verifies against the real wall clock, -/// so the JS suite can trust `jwks.json` and `response.jws.json` without -/// re-deriving them. +/// The committed policy fixture stands for a document a relying party writes, +/// so the frozen verification-policy contract is what decides whether it is a +/// policy anyone could actually adopt. It once was not: it named an acceptance +/// window longer than the contract's own ceiling, which made the fixture a model +/// of something no conformant relying party could express. +#[test] +fn the_committed_policy_conforms_to_the_verification_policy_contract() { + let contract: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/verification-policy.schema.yaml" + )) + .expect("the verification policy contract is YAML"); + let contract = serde_json::to_value(contract).expect("the contract converts to JSON"); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .compile(&contract) + .expect("the verification policy contract compiles"); + + let policy: serde_json::Value = serde_json::from_slice( + &fs::read(fixtures_dir().join("policy.json")).expect("the policy fixture exists"), + ) + .expect("the policy fixture parses"); + + let violations: Vec = match validator.validate(&policy) { + Ok(()) => Vec::new(), + Err(errors) => errors + .map(|error| format!("{error}, at {}", error.instance_path)) + .collect(), + }; + assert!( + violations.is_empty(), + "the committed policy fixture violates its contract:\n{}", + violations.join("\n") + ); +} + +/// Confirms the committed response, key set, and policy still agree, at an +/// instant inside the acceptance window the fixture states rather than at the +/// wall clock, so the JS suite can trust `jwks.json` and `response.jws.json` +/// without re-deriving them. Pinning the instant is what lets the fixture carry +/// a lifetime a relying party could adopt: it does not have to outlive the gaps +/// between regenerations. #[test] -fn golden_fixture_verifies_against_the_real_clock() { +fn the_committed_fixture_verifies_at_its_pinned_instant() { let dir = fixtures_dir(); let jws_bytes = fs::read(dir.join("response.jws.json")).expect("the response fixture exists"); let jwks: JwksDocument = @@ -198,15 +273,29 @@ fn golden_fixture_verifies_against_the_real_clock() { ) .expect("the policy fixture parses"); - let policy = policy_document.into_policy(Utc::now()); + let policy = policy_document.into_policy(fixture_issued_at() + ChronoDuration::days(1)); let evidence = verify_flattened_jws(&jws_bytes, &jwks, &policy).expect("the fixture verifies"); - assert_eq!(evidence.request_nonce, FIXTURE_NONCE); - assert_eq!(evidence.subjects.len(), 1); - assert_eq!(evidence.subjects[0].role, "subject"); - assert_eq!(evidence.supported_values.len(), 1); - assert!(matches!( - evidence.supported_values[0].value, - PublicValue::Boolean(true) - )); + assert_fixture_shape(&evidence); +} + +/// The real-clock half of the same coverage. A response signed now and verified +/// now keeps the wall-clock path through `into_policy` and +/// `verify_flattened_jws` exercised, without any committed file having to stay +/// current for years to do it. +#[tokio::test] +async fn a_freshly_signed_response_verifies_against_the_real_clock() { + let signer = fixture_signer(); + let issued_at = Utc::now(); + let valid_until = issued_at + ChronoDuration::days(FIXTURE_LIFETIME_DAYS); + let evidence = fixture_evidence(issued_at, valid_until); + let policy_document = fixture_policy_document(&evidence); + let jws_bytes = serde_json::to_vec(&sign(&evidence, &signer).await) + .expect("the signed response serializes"); + + let policy = policy_document.into_policy(Utc::now()); + let verified = verify_flattened_jws(&jws_bytes, &public_jwks(&signer), &policy) + .expect("a freshly signed response verifies"); + + assert_fixture_shape(&verified); } diff --git a/crates/registry-evidence-client-py/Cargo.toml b/crates/registry-evidence-client-py/Cargo.toml index 9474c4993..50814c620 100644 --- a/crates/registry-evidence-client-py/Cargo.toml +++ b/crates/registry-evidence-client-py/Cargo.toml @@ -48,6 +48,11 @@ pyo3 = { workspace = true, features = ["auto-initialize"] } base64.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true +# The committed policy fixture stands for a relying party's own verification +# policy document, so the tests validate it against the frozen contract that +# governs one. `jsonschema` compiles that contract and `serde_norway` reads it. +jsonschema.workspace = true registry-evidence-verifier.workspace = true serde.workspace = true +serde_norway.workspace = true wiremock.workspace = true diff --git a/crates/registry-evidence-client-py/tests/fixtures/jwks.json b/crates/registry-evidence-client-py/tests/fixtures/jwks.json index ebadc4173..fd4cba6c7 100644 --- a/crates/registry-evidence-client-py/tests/fixtures/jwks.json +++ b/crates/registry-evidence-client-py/tests/fixtures/jwks.json @@ -5,7 +5,7 @@ "crv": "Ed25519", "kid": "evidence-python-fixture-key-1", "kty": "OKP", - "x": "TNEiCl1Dmh2rsbO3-TDo_dTjwID04eQq079z-tEPEHM" + "x": "uv8kus_GPzFwIEsLwrmF1TPFSC9CMkk51U8pxkO2SeM" } ] } diff --git a/crates/registry-evidence-client-py/tests/fixtures/policy.json b/crates/registry-evidence-client-py/tests/fixtures/policy.json index c16615da0..62e8f3321 100644 --- a/crates/registry-evidence-client-py/tests/fixtures/policy.json +++ b/crates/registry-evidence-client-py/tests/fixtures/policy.json @@ -20,6 +20,6 @@ "form": "boolean" } ], - "maximumAssertionLifetimeSeconds": 315360000, + "maximumAssertionLifetimeSeconds": 2592000, "clockSkewSeconds": 30 } diff --git a/crates/registry-evidence-client-py/tests/fixtures/response.jws.json b/crates/registry-evidence-client-py/tests/fixtures/response.jws.json index 8bda6da98..018dd97a9 100644 --- a/crates/registry-evidence-client-py/tests/fixtures/response.jws.json +++ b/crates/registry-evidence-client-py/tests/fixtures/response.jws.json @@ -1,5 +1,5 @@ { "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2aWRlbmNlLXB5dGhvbi1maXh0dXJlLWtleS0xIiwidHlwIjoiZXZpZGVuY2UrandzIiwiY3R5IjoiYXBwbGljYXRpb24vZXZpZGVuY2UranNvbiJ9", - "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpweXRob24tZml4dHVyZSIsInR5cGUiOiJFdmlkZW5jZSIsInN1cHBvcnRzUmVxdWlyZW1lbnQiOiJ1cm46ZXhhbXBsZTpyZXF1aXJlbWVudDp2MSIsImlzQ29uZm9ybWFudFRvIjoidXJuOmV4YW1wbGU6ZXZpZGVuY2UtdHlwZTp2MSIsImlzc3VlZEJ5IjoidXJuOmV4YW1wbGU6aXNzdWVyIiwicHJvdmlkZWRCeSI6InVybjpleGFtcGxlOnByb3ZpZGVyIiwiaXNzdWVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsIm9ic2VydmVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsInZhbGlkVW50aWwiOiIyMDM2LTA3LTI5VDAwOjAwOjAwWiIsInB1cnBvc2UiOiJleGFtcGxlLXB1cnBvc2UiLCJhdWRpZW5jZSI6InVybjpleGFtcGxlOmF1ZGllbmNlIiwiY29uZmlndXJhdGlvblJldmlzaW9uIjoic2hhMjU2OjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJzdWJqZWN0cyI6W3sicm9sZSI6InN1YmplY3QiLCJiaW5kaW5nIjoidXJuOmV2aWRlbmNlOnN1YmplY3Q6djFfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9XSwic3VwcG9ydGVkVmFsdWVzIjpbeyJwcm92aWRlc1ZhbHVlRm9yIjoidXJuOmV4YW1wbGU6Y29uY2VwdDpzdGF0dXMtaG9sZHMiLCJ2YWx1ZSI6dHJ1ZX1dfQ", - "signature": "KM0xZJlpB728jz3qpFSy0J4Q8D9-YQkbwVn6j873DEaGwohADoviqRvPRUoHNVApOxC3XFAYaNZjsfgxUgs-DA" + "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpweXRob24tZml4dHVyZSIsInR5cGUiOiJFdmlkZW5jZSIsInN1cHBvcnRzUmVxdWlyZW1lbnQiOiJ1cm46ZXhhbXBsZTpyZXF1aXJlbWVudDp2MSIsImlzQ29uZm9ybWFudFRvIjoidXJuOmV4YW1wbGU6ZXZpZGVuY2UtdHlwZTp2MSIsImlzc3VlZEJ5IjoidXJuOmV4YW1wbGU6aXNzdWVyIiwicHJvdmlkZWRCeSI6InVybjpleGFtcGxlOnByb3ZpZGVyIiwiaXNzdWVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsIm9ic2VydmVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsInZhbGlkVW50aWwiOiIyMDI2LTA4LTMxVDAwOjAwOjAwWiIsInB1cnBvc2UiOiJleGFtcGxlLXB1cnBvc2UiLCJhdWRpZW5jZSI6InVybjpleGFtcGxlOmF1ZGllbmNlIiwiY29uZmlndXJhdGlvblJldmlzaW9uIjoic2hhMjU2OjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJzdWJqZWN0cyI6W3sicm9sZSI6InN1YmplY3QiLCJiaW5kaW5nIjoidXJuOmV2aWRlbmNlOnN1YmplY3Q6djFfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9XSwic3VwcG9ydGVkVmFsdWVzIjpbeyJwcm92aWRlc1ZhbHVlRm9yIjoidXJuOmV4YW1wbGU6Y29uY2VwdDpzdGF0dXMtaG9sZHMiLCJ2YWx1ZSI6dHJ1ZX1dfQ", + "signature": "I_pS9ltaQMAKwXq3O6FryRm9QpNlrE_3NLOq3zebvoW1HyHi8uD__ho13XVvf78f7AVP4Zyg5BdR2mJSl-xDDA" } diff --git a/crates/registry-evidence-client-py/tests/golden_fixture.rs b/crates/registry-evidence-client-py/tests/golden_fixture.rs index d35a94168..3dfdc56c8 100644 --- a/crates/registry-evidence-client-py/tests/golden_fixture.rs +++ b/crates/registry-evidence-client-py/tests/golden_fixture.rs @@ -53,16 +53,67 @@ const FIXTURE_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const ACTIVE_KEY_ID: &str = "evidence-python-fixture-key-1"; -/// Ten years past `issued_at`. The Python suite runs this fixture through -/// `discover`/`fetch_jwks` stubs indefinitely into the future, so its -/// validity window has to outlive ordinary gaps between regenerations, not -/// just the day it was generated. -const FIXTURE_LIFETIME_DAYS: i64 = 3650; +/// The instant the committed response is signed for, shared by the generator +/// and by every check that reads the result, so nothing has to re-derive it +/// from the committed bytes. +const FIXTURE_ISSUED_AT: &str = "2026-08-01T00:00:00Z"; + +/// Thirty days past `FIXTURE_ISSUED_AT`, which is also the acceptance ceiling +/// the committed policy states. It has to stay inside the +/// `maximumAssertionLifetimeSeconds` bound the verification-policy contract +/// sets, or the fixture would model a policy no conformant relying party could +/// express. Nothing here needs a longer window: the checks that read the +/// committed response verify at a pinned instant rather than at the wall clock, +/// and the real-clock path is covered by signing fresh evidence instead. +const FIXTURE_LIFETIME_DAYS: i64 = 30; fn fixtures_dir() -> &'static Path { Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")) } +fn fixture_issued_at() -> DateTime { + FIXTURE_ISSUED_AT + .parse() + .expect("the fixture instant parses") +} + +/// A fresh Ed25519 signer under the fixture's key id. The private half never +/// leaves the process that made it: regeneration commits only the public key, +/// and the real-clock check below discards the whole pair when it returns. +fn fixture_signer() -> LocalJwkSigner { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("the host supplies randomness"); + let signing_key = SigningKey::from_bytes(&seed); + let private_jwk_json = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "kid": ACTIVE_KEY_ID, + "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + }); + let private_jwk = + PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); + LocalJwkSigner::new(private_jwk).expect("the generated key signs") +} + +fn public_jwks(signer: &LocalJwkSigner) -> JwksDocument { + JwksDocument { + keys: vec![serde_json::to_value(signer.public_jwk()).expect("the public key serializes")], + } +} + +fn assert_fixture_shape(evidence: &Evidence) { + assert_eq!(evidence.request_nonce, FIXTURE_NONCE); + assert_eq!(evidence.subjects.len(), 1); + assert_eq!(evidence.subjects[0].role, "subject"); + assert_eq!(evidence.supported_values.len(), 1); + assert!(matches!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + )); +} + fn fixture_evidence(issued_at: DateTime, valid_until: DateTime) -> Evidence { Evidence { schema: EVIDENCE_SCHEMA_V1.to_owned(), @@ -168,29 +219,14 @@ fn write_pretty(path: &Path, value: &T) { #[tokio::test] #[ignore] async fn regenerate_golden_fixture() { - let mut seed = [0_u8; 32]; - getrandom::fill(&mut seed).expect("the host supplies randomness"); - let signing_key = SigningKey::from_bytes(&seed); - let private_jwk_json = serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": ACTIVE_KEY_ID, - "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), - "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - }); - let private_jwk = - PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); - let signer = LocalJwkSigner::new(private_jwk).expect("the generated key signs"); + let signer = fixture_signer(); - let issued_at: DateTime = "2026-08-01T00:00:00Z".parse().expect("issued_at parses"); + let issued_at = fixture_issued_at(); let valid_until = issued_at + ChronoDuration::days(FIXTURE_LIFETIME_DAYS); let evidence = fixture_evidence(issued_at, valid_until); let policy_document = fixture_policy_document(&evidence); let jws = sign(&evidence, &signer).await; - let jwks = JwksDocument { - keys: vec![serde_json::to_value(signer.public_jwk()).expect("the public key serializes")], - }; + let jwks = public_jwks(&signer); let dir = fixtures_dir(); fs::create_dir_all(dir).expect("the fixtures directory can be created"); @@ -199,11 +235,50 @@ async fn regenerate_golden_fixture() { write_pretty(&dir.join("policy.json"), &policy_document); } -/// Confirms the committed fixture still verifies against the real wall clock, -/// so the Python suite can trust `jwks.json` and `response.jws.json` without -/// re-deriving them. +/// The committed policy fixture stands for a document a relying party writes, +/// so the frozen verification-policy contract is what decides whether it is a +/// policy anyone could actually adopt. It once was not: it named an acceptance +/// window longer than the contract's own ceiling, which made the fixture a model +/// of something no conformant relying party could express. +#[test] +fn the_committed_policy_conforms_to_the_verification_policy_contract() { + let contract: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/verification-policy.schema.yaml" + )) + .expect("the verification policy contract is YAML"); + let contract = serde_json::to_value(contract).expect("the contract converts to JSON"); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .compile(&contract) + .expect("the verification policy contract compiles"); + + let policy: serde_json::Value = serde_json::from_slice( + &fs::read(fixtures_dir().join("policy.json")).expect("the policy fixture exists"), + ) + .expect("the policy fixture parses"); + + let violations: Vec = match validator.validate(&policy) { + Ok(()) => Vec::new(), + Err(errors) => errors + .map(|error| format!("{error}, at {}", error.instance_path)) + .collect(), + }; + assert!( + violations.is_empty(), + "the committed policy fixture violates its contract:\n{}", + violations.join("\n") + ); +} + +/// Confirms the committed response, key set, and policy still agree, at an +/// instant inside the acceptance window the fixture states rather than at the +/// wall clock, so the Python suite can trust `jwks.json` and +/// `response.jws.json` without re-deriving them. Pinning the instant is what +/// lets the fixture carry a lifetime a relying party could adopt: it does not +/// have to outlive the gaps between regenerations. #[test] -fn golden_fixture_verifies_against_the_real_clock() { +fn the_committed_fixture_verifies_at_its_pinned_instant() { let dir = fixtures_dir(); let jws_bytes = fs::read(dir.join("response.jws.json")).expect("the response fixture exists"); let jwks: JwksDocument = @@ -214,15 +289,29 @@ fn golden_fixture_verifies_against_the_real_clock() { ) .expect("the policy fixture parses"); - let policy = policy_document.into_policy(Utc::now()); + let policy = policy_document.into_policy(fixture_issued_at() + ChronoDuration::days(1)); let evidence = verify_flattened_jws(&jws_bytes, &jwks, &policy).expect("the fixture verifies"); - assert_eq!(evidence.request_nonce, FIXTURE_NONCE); - assert_eq!(evidence.subjects.len(), 1); - assert_eq!(evidence.subjects[0].role, "subject"); - assert_eq!(evidence.supported_values.len(), 1); - assert!(matches!( - evidence.supported_values[0].value, - PublicValue::Boolean(true) - )); + assert_fixture_shape(&evidence); +} + +/// The real-clock half of the same coverage. A response signed now and verified +/// now keeps the wall-clock path through `into_policy` and +/// `verify_flattened_jws` exercised, without any committed file having to stay +/// current for years to do it. +#[tokio::test] +async fn a_freshly_signed_response_verifies_against_the_real_clock() { + let signer = fixture_signer(); + let issued_at = Utc::now(); + let valid_until = issued_at + ChronoDuration::days(FIXTURE_LIFETIME_DAYS); + let evidence = fixture_evidence(issued_at, valid_until); + let policy_document = fixture_policy_document(&evidence); + let jws_bytes = serde_json::to_vec(&sign(&evidence, &signer).await) + .expect("the signed response serializes"); + + let policy = policy_document.into_policy(Utc::now()); + let verified = verify_flattened_jws(&jws_bytes, &public_jwks(&signer), &policy) + .expect("a freshly signed response verifies"); + + assert_fixture_shape(&verified); } diff --git a/crates/registry-evidence-client-py/tests/happy_path.rs b/crates/registry-evidence-client-py/tests/happy_path.rs index 21bc9dff3..7b410a228 100644 --- a/crates/registry-evidence-client-py/tests/happy_path.rs +++ b/crates/registry-evidence-client-py/tests/happy_path.rs @@ -34,7 +34,7 @@ use std::{fs, path::Path}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use chrono::{Duration as ChronoDuration, Utc}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; use ed25519_dalek::{Signer, SigningKey}; use evidence_client_sdk::{ AssuranceProfile, Evidence, EvidenceObjectType, JwksDocument, PublicValue, SubjectBinding, @@ -51,6 +51,11 @@ use wiremock::{ const KEY_ID: &str = "evidence-python-live-key-1"; +/// The instant the golden fixture is signed for, restated from +/// `tests/golden_fixture.rs` rather than shared with it: every file under +/// `tests/` compiles as its own crate. +const FIXTURE_ISSUED_AT: &str = "2026-08-01T00:00:00Z"; + /// The specification every send/verify test in this file prepares against, /// as the plain Python-facing (snake_case) shape `spec_from_json` expects. /// `subject_expectations` is `"accept_first_use"`, so verification pins @@ -395,6 +400,11 @@ fn a_second_send_is_refused_without_reaching_the_deployment() { /// canonical nonce, so checking it against a freshly prepared request (whose /// nonce is different on every run) has to fail verification, not just /// happen to succeed by construction. +/// +/// Verified through `verify_as_of` at an instant inside the fixture's own +/// validity window, so the nonce mismatch stays the reason it fails. Against +/// the wall clock, the fixture would eventually expire and this case would go +/// on passing for a reason it was never written to prove. #[test] fn a_stale_fixture_response_fails_verification_against_a_live_prepared_request() { let fixtures_dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")); @@ -461,8 +471,15 @@ fn a_stale_fixture_response_fails_verification_against_a_live_prepared_request() .call_method1("send", (&prepared,)) .expect("the stub answers, with its stale fixture body"); + let as_of = FIXTURE_ISSUED_AT + .parse::>() + .expect("the fixture instant parses") + + ChronoDuration::days(1); let error = client - .call_method1("verify", (&prepared, &response)) + .call_method1( + "verify_as_of", + (&prepared, &response, as_of.timestamp() as f64), + ) .expect_err("a response signed for a different nonce fails verification"); let kind: String = error .value(py) @@ -471,5 +488,15 @@ fn a_stale_fixture_response_fails_verification_against_a_live_prepared_request() .extract() .expect("kind is a string"); assert_eq!(kind, "verification"); + let code: String = error + .value(py) + .getattr("code") + .expect("the exception carries a code") + .extract() + .expect("code is a string"); + // The one generic class every failed policy comparison reports, the + // expected nonce included. `time` here would mean the fixture expired + // instead, which is the outcome the pinned instant rules out. + assert_eq!(code, "policy"); }); }