feat(evidence): add a relying-party client library with Node and Python bindings - #649
feat(evidence): add a relying-party client library with Node and Python bindings#649jeremi wants to merge 45 commits into
Conversation
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
…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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
…nt 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 <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
…overage 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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
…ixture 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 <jeremi@joslin.fr>
…rkspace 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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
…nvelope 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 <jeremi@joslin.fr>
…tion `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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
…entories 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 <jeremi@joslin.fr>
Two published pages describe that CI job by listing its steps, and it also runs the verifier portability script. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
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 <jeremi@joslin.fr>
| res.end('no stub route for this request'); | ||
| return; | ||
| } | ||
| handler(req, res, body); |
| } | ||
| return null | ||
| } | ||
| if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { |
| if str(_IMPORT_DIR) not in sys.path: | ||
| sys.path.insert(0, str(_IMPORT_DIR)) | ||
|
|
||
| _built = True |
| try: | ||
| barrier.wait(timeout=JOIN_TIMEOUT_SECONDS) | ||
| client.discover() | ||
| except BaseException as error: # noqa: BLE001 - captured, not swallowed |
| try: | ||
| while not stop_event.is_set(): | ||
| counter[0] += 1 | ||
| except BaseException as error: # noqa: BLE001 - captured, not swallowed |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
💡 Codex Review
registry-stack/crates/registry-evidence-verifier/src/verifier.rs
Lines 766 to 770 in 2a12242
When an SD-JWT VC includes cnf.jwk with kty: "OKP", crv: "Ed25519", and alg: "EdDSA" but a missing or malformed x coordinate, serde_json::from_value still constructs a PublicJwk and these checks pass because algorithm() only inspects the metadata fields. That accepts a credential outside the frozen profile's OKP Ed25519 public JWK constraint; parse the confirmation through the same public-key validation used at issuance (or otherwise require the canonical 32-byte coordinate) before returning Ok(()).
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter | ||
| .debug_struct("PrivateKeyJwtConfig") | ||
| .field("token_endpoint", &self.token_endpoint.as_str()) |
There was a problem hiding this comment.
Redact userinfo from token endpoint debug output
When a caller formats PrivateKeyJwtConfig before PrivateKeyJwt::new validates it, a token endpoint like https://client:secret@issuer.example/token is rendered verbatim here. The constructor later rejects userinfo, but this Debug implementation is available on the unvalidated config, so configuration diagnostics can log the credential that the Evidence rules require keeping out of logs.
Useful? React with 👍 / 👎.
| 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)); |
There was a problem hiding this comment.
Reject ambiguous token provider configs
If a Node caller supplies both token.static and token.privateKeyJwt (or token.static plus any stray key), this branch silently selects the static bearer token and ignores the rest, despite the error contract saying exactly one provider is allowed. That makes a typo or merge of two auth configurations run with the wrong credential instead of failing closed; the Python binding already rejects objects whose token mapping has more than one key.
Useful? React with 👍 / 👎.
| client_bindings = complete or any( | ||
| path.startswith(f"crates/{package}/") | ||
| for path in paths | ||
| for package in EVIDENCE_BINDING_PACKAGES | ||
| ) |
There was a problem hiding this comment.
Run binding jobs for SDK changes too
Because this output only looks at paths under the two binding crates, a change in crates/registry-evidence-client or crates/registry-evidence-verifier can change the native surface or error envelope that the Node/Python packages wrap while leaving client-bindings false, so the npm tests/type-drift check and Python unittest suite are skipped. The binding crates are reverse-dependencies of the SDK, so this should be based on affected packages (or include the SDK/verifier paths), not just direct binding file paths.
AGENTS.md reference: AGENTS.md:L158-L175
Useful? React with 👍 / 👎.
| "{what} must be a finite, non-negative number of seconds" | ||
| ))); | ||
| } | ||
| Ok(Duration::from_secs_f64(seconds)) |
There was a problem hiding this comment.
Bound Python timeout floats before conversion
For a Python caller who passes a very large but finite timeout such as 1e300 (either top-level or inside private_key_jwt), the guard lets the value reach Duration::from_secs_f64, which panics on values too large for Duration. That turns bad configuration input into a PyO3 panic path instead of the mapped ConfigurationError; reject values above Duration::MAX before calling this constructor.
Useful? React with 👍 / 👎.
| client_bindings = complete or any( | ||
| path.startswith(f"crates/{package}/") | ||
| for path in paths | ||
| for package in EVIDENCE_BINDING_PACKAGES | ||
| ) |
There was a problem hiding this comment.
Run binding jobs for SDK changes too
Because this output only looks at paths under the two binding crates, a change in crates/registry-evidence-client or crates/registry-evidence-verifier can change the native surface or error envelope that the Node/Python packages wrap while leaving client-bindings false, so the npm tests/type-drift check and Python unittest suite are skipped. The binding crates are reverse-dependencies of the SDK, so this should be based on affected packages (or include the SDK/verifier paths), not just direct binding file paths.
Useful? React with 👍 / 👎.
| # `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" |
There was a problem hiding this comment.
Use the package-qualified extension module name
With python-source = "python", Maturin's mixed-project layout places a Rust extension inside the Python package only when module-name is the full import path, e.g. registry_evidence_client.registry_evidence_client. As written, the wheel builds a top-level extension next to the python/registry_evidence_client package; Python imports the package directory first, runs __init__.py, and from .registry_evidence_client import * then cannot find the compiled submodule. The local unittest bootstrap copies a top-level .so directly, so it does not exercise this wheel layout.
Useful? React with 👍 / 👎.
Pull Request
Summary
Adds a relying-party client library for Evidence in Rust, with Node.js and
Python bindings, and extracts the response-verification code the library needs
into its own portable crate.
Five areas, in dependency order:
crates/registry-evidence-verifier, extracted out ofcrates/registry-evidence. It owns the response wire formats, the Evidencepayload contract, and relying-party verification, so a relying party can
verify a signed Evidence response without the runtime. It carries no server,
source access, or service-runtime dependency, enforced by
products/evidence/scripts/check-verifier-portability.sh. Portable theremeans free of the service runtime, not target independent: the crypto stack
reaches
aws-lc-sys, so a build needs a C toolchain and excludeswasm32.crates/registry-evidence-client: the SDK. Prepares a fixed request,sends it, and verifies the response through the verifier crate. Rust owns the
transport end to end (reqwest), so both bindings inherit one HTTP, TLS, and
trust-pinning implementation rather than three. Covers discovery, JWKS
pinning, one-send enforcement, and bounded response reading.
PrivateKeyJwttoken provider in that same crate: a generic RFC 7523private-key-JWT client, proven against a real
registry-mintinstance ratherthan a stub, so deployments with no identity provider can acquire access
tokens.
crates/registry-evidence-client-node: napi-rs binding.crates/registry-evidence-client-py: PyO3 + abi3-py310 + maturinbinding.
Signed flattened JWS only. SD-JWT VC responses are deliberately out of scope for
this version, as is publishing either binding package (see Notes).
Owning area
This spans more than one owning area, against the usual rule. Adding crates that
the product boundary documents enumerate by name means the crates and those
documents move together:
AGENTS.md,products/evidence/*, and the docs-sitespec pages all name the runtime's file set, so leaving them behind would ship a
boundary document that contradicts the tree. The
.githubandrelease/touches are the CI wiring and gate inventory the new crates need. The three
crates/registry-relayfiles are a consequence of a workspace dependencychange, described under Notes. The fix pass that followed review kept each of
its commits inside one owning area.
Checks
Every gate below ran at the tip of this branch, after the rebase onto
mainandafter the review fix pass, with its exit code captured.
cargo fmt --checkcargo check --locked --workspace --all-targetscargo clippy --locked --workspace --all-targets -- -D warningscargo test --locked --workspacecargo test --locked -p registry-relay --all-featurescargo test --locked -p registry-evidence-verifiercargo test --locked -p registry-evidence-clientcargo test --locked -p registry-evidence-client-nodecargo test --locked -p registry-evidence-client-pycargo deny checkproducts/evidence/scripts/check-contracts.shproducts/evidence/scripts/check-source-neutrality.shproducts/evidence/scripts/check-verifier-portability.shpython3 .github/scripts/test_ci_changes.pypython3 -m unittest release/scripts/test_registry_release.pypython3 release/scripts/check-gates-inventory.pyREGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.shpython3 -m unittest release/scripts/test_check_release_source_model.pyrelease/scripts/registry-release validate release/manifests/registry-stack-beta-27.yamlnpm ci,npm run build:debug,npm test,npm run check:typesin the Node cratepython3 -m unittest discover -s tests/pythonin the Py cratecmp ../../LICENSE LICENSEin both binding cratesnpm testandnpm run checkindocs/siteThe
registry-relay --all-featuresrow is the one that matters for the relayhalf of this branch:
spdci-api-standardsis not in relay's default featureset, so a default
cargo test -p registry-relaycompiles the eight new schematests to zero tests. In CI they run through the
rust-testsrelay shard's--all-featuresinvocation.Two local-only notes, neither committed anywhere: on macOS
cargo test -p registry-evidence-client-pyneeds the resolved interpreter'slibrary directory on
DYLD_LIBRARY_PATH, or the test binary aborts at startupon
libpython3.13.dylibbefore running a test (this is documented in thatcrate's README as a build note, without a machine-specific path). Without it,
cargo test --workspacefails at that package with exit 101, which is adynamic-linker failure and not a test failure. Linux resolves the library
through the loader cache and needs nothing.
Coverage for the functionality
crates/registry-evidence-client/tests/against_a_real_deployment.rsruns thereal
evidencebinary and the realmintbinary, and covers first-useacceptance then pinning, a pinned binding refusing an assertion about another
subject, a response that cannot verify against another prepared request, a
request the deployment cannot answer, discovery shapes, and the published key
set being the deployment's own.
covers an acquired credential completing a verified exchange, a cached
credential serving every request inside its window, a credential inside the
refresh margin being replaced, an unregistered client key refused without
detail, and a tampered credential refused without detail.
the wrong media type, and a credential without the configured tag are each
refused, with their own tests.
credential, signing key, selector value, or subject binding reaches an error
message, a
Debugrendering, or a log. The Python one drives a canary valuethrough six distinct failure arrangements.
hand-written JS in step with the generated native surface. The Python crate
has a concurrency test proving two calls overlap rather than serialize, a
test proving the constructor releases the GIL for its Rust work, and a reload
test proving the public surface survives re-importing the package.
Generated outputs
Both regenerated by their documented generator commands, never hand-edited, and
confirmed reproducible:
crates/registry-evidence-client-node/index.jsvianpm run build(
napi build --platform --release), byte-identical across two runs.docs/site/src/data/generated/projects.jsonvianpm run generate, whichnpm run checkruns first: the tree stays clean afterwards.Notes
The review pass this branch already went through
The whole branch was reviewed before this PR was opened, dimension by dimension,
against the frozen Version 1 contract, the Evidence product boundary, the
redaction rules, the binding surfaces, and the relay change. That review
returned no blockers, eighteen findings worth fixing, and nineteen observations.
Nine commits at the tip of this branch close the fixable ones. In order of what
they change: two redaction gaps (subject bindings surviving a
Debugrendering,and base-URL userinfo surviving the client config's
Debug); an unusableasOfMillisreaching the Node error envelope as the wrong kind; four committedstatements about the Python binding's panic surface that omitted the
allocation-failure caveat; the client boundary and the binding check commands
missing from
AGENTS.md; the syscall layers the portability gate actuallydenies, unnamed in four documents; the client crates missing from the Evidence
gate lists and crate inventories; one incomplete CI job enumeration in the docs
site; and the relay SP DCI response-schema contract, which is the only one of
the nine that changes behavior rather than words.
What the review found and this branch does not fix is in Flagged,
deliberately not fixed here below, all twenty-nine items, including the ones
that are pre-existing on
main.Security-sensitive review notes
This change touches authentication, assertion verification, and data
minimization, so per
AGENTS.mdhere is what a reviewer should look atdeliberately.
contract checks confirm the Evidence contracts still reproduce exactly, and
the portability check confirms the extracted crate pulls in no server, source
access, or service-runtime dependency. The risk to review is whether any
verification step was weakened in the move; the runtime's own tests still
exercise it through the new crate.
builds reqwest with
use_rustls_tls()and, when a caller pins roots, callstls_built_in_root_certs(false), which clears both the webpki and native rootflags. A pinned client therefore cannot silently accept a platform root.
configuration flag that skips signature verification, no "insecure" mode, and
no path that returns an unverified payload to a caller. The one-send guard
refuses a second send on the same prepared request without reaching the
network.
No Evidence crate depends on Mint at runtime.
registry-mintis adev-dependency of
crates/registry-evidence-clientonly, so the integrationsuite can prove the RFC 7523 assertion against a real verifier instead of a
stub told to return 401. This made one sentence in the root
AGENTS.mdfalsein the build graph, so that sentence now confines its claim to production and
states that Evidence test code may drive a real Mint instance. That edit is in
its own commit and can be reviewed or dropped alone.
BearerTokenwraps aZeroizing<String>, the signed client assertion and the token request body arebuilt in
Zeroizingbuffers, and theAuthorizationheader is assembled inone too. The private signing key is the shared
PrivateJwk, which zeroizes itsprivate members (
d,p,q,dp,dq,qi) in a hand-writtenDropandderives neither
DebugnorSerialize, so it cannot be printed or serializedat all. Wire types render through a
redacted_debug!macro, and no credential,key member, selector value, or subject binding is interpolated into an error
message. Acquisition failures report only the registered error code from the
authorization server, and unregistered codes collapse to one name rather than
echoing server text.
ExpectedSubjectDocumentinto public SDK surface made a pinned subjectbinding renderable by any relying party that formatted a retained policy,
because the type derived
Debugover its binding field. It andExpectedSubjectnow render the role and elide the binding, matching thehand-written
DebugthatSubjectExpectationsalready carried, with threetests driving a canary binding through the derive path including through the
policy document that still derives
Debug. Separately, the client config'shand-written
Debugpromised to withhold the credential source whilerendering the base URL verbatim, so a config formatted before
EvidenceClient::newranvalidate()rendered any userinfo password thecaller had put in it. Nothing shipped logged either value, so both were
widened latent surface rather than active leaks, and each is a
data-minimization fix in its own commit.
Ulid::new()inthe private-key-JWT path reaches
rand::rng(), which panics on entropyfailure. It is reachable only in private-key-JWT deployments, its text carries
no secret, and short of the Python binding's
set_attr!assertions, which canfire only under allocation failure, a static-bearer deployment has no
reachable panic at all. Review found that caveat missing from three committed
statements, which now carry it.
Request nonces are guarded instead, through
getrandom::fill(...). Bothbindings contain the unwind rather than letting it cross the FFI boundary; the
Node crate's doc comment records that its synchronous path redacts panic text
while napi's async path does not, since the rejection is built inside napi and
this crate cannot change it.
credential, token, live response, or demo-subject identifier is committed.
Decisions that want explicit sign-off
workspace lint table holds exactly
unsafe_code = "forbid".forbidcannot berelaxed by an inner
#[allow], including the#[allow(unsafe_code)]thatnapi-derive emits into its generated FFI registration glue, so a crate that
inherits the table cannot compile against napi-rs at all. That crate therefore
omits
[lints] workspace = true, with a comment giving the reason, and puts#![deny(unsafe_code)]at the top ofsrc/lib.rsinstead: its own sourcestill cannot contain unsafe code, while its dependencies' generated glue can.
This is genuinely weaker than what every other crate is held to, and the real
choice is this opt-out or no Node binding. The Python binding needs no such
override: it keeps
[lints] workspace = trueand compiles and lints cleanunder inherited
forbid, verified with and without theextension-modulefeature.
publish = false, and thisbranch adds no npm publish workflow, no PyPI upload, no release manifest
entry, and no cross-platform artifact matrix. Building and testing on the CI
runner is the bar set here. The consequence: an adopter outside this
repository cannot install either package, so what merges is a working, tested
binding usable only from a checkout with a Rust toolchain. Distribution
carries its own decisions (release provenance, a per-platform artifact matrix,
who owns the npm scope and the PyPI name, whether either belongs in the
release manifest) and each wants review on its own.
products/evidence/IMPLEMENTATION.md's approved prohibition was amended.It forbade creating "client, worker, adapter, policy, credential, or
interoperability crates in version one". This branch first replaced that with a
prohibition on decomposing the runtime, which review judged weaker than what it
replaced: dropping
clientfrom an enumerative list read as permitting anydecomposition not literally named. The clause now keeps the original list with
clientin it and states the distinction that makes this branch legal, whichis that 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
of its own. It also names
registry-evidence-verifieras the one approveddecomposition of the runtime and declares it closed, so a future reader cannot
read the extraction as licence for a second one, and its carve-out covers the
SDK and both bindings instead of a singular "client library". The reasoning for
amending at all: the prohibition sits in a section enumerating the runtime's own
files, and the same document already depends on a separate
evidencectlcrate,so separate non-runtime crates were never what it forbade. It is still an
approved governance statement being edited.
Compatibility and release
A workspace
jsonschemachange reaches relay, in two directions, and reviewproved the first draft of this note wrong. The workspace entry disables
default features, since every consumer compiles schemas it already holds in
memory and neither the remote-
$refHTTP client nor the bundled CLI parserbelongs in the dependency graph. Pointing relay's two pins at that entry drops
resolve-httpandresolve-fileand addsdraft202012. Onmaintheoptional dependency took default features while only the dev-dependency added
draft202012, so relay's tests compiled adopter schemas under a differentdraft than the shipped binary did.
This note previously claimed such a schema "now fails config validation with
spdci.config.schema_compile_failed", which the shipped configuration guidealso claimed. Both were wrong:
$refresolution in that compiler is lazy, soa schema carrying an external
$refloads and then fails every record atrequest time, and SP DCI generic search, details, and support answer
500 internal.unhandledfor any non-empty result. What the pin genuinely changes isthat no request is made and no file is read at all, where a default-featured
build resolved a remote
$refwhile serving a record. Thedraft202012halfis the one that can silently widen what a schema accepts: a schema declaring
2020-12 compiled under the draft 7 fallback in the shipped binary, which
asserts
format, and now compiles as 2020-12, which treatsformatas anannotation, so an adopter relying on
formatto constrain a value needs anexplicit
patternorenum.crates/registry-relay/docs/configuration.mdnow states both halves, thedraft each schema compiles under, and the silent draft 7 fallback for an
uncarried draft such as 2019-09. Eight characterization tests in
crates/registry-relay/tests/spdci_config_validation.rspin all of it throughconfig::loadand the response mapper, including that no request reaches amock upstream serving the referenced document, and each was proven
load-bearing against a throwaway crate that toggles the compiler's features.
crates/registry-relay/CHANGELOG.mdcarries the entry. The adapter is builtonly with
--features spdci-api-standards, which released images do not carry(
crates/registry-relay/canonical-release-features.txt), so this reaches anadopter who builds it deliberately. Those tests run in CI through the
rust-testsjob's relay shard, which passes--all-features; the defaultcargo test -p registry-relaycompiles them to zero tests.crates/registry-relay/Cargo.tomlpins reqwest withnative-tls, so aworkspace-scope build compiles both TLS backends. Verified not to weaken the
client's trust pinning, since
use_rustls_tls()sets the backend thatbuild()dispatches on. Named because it is a supply-chain surface therepository carries, and explicitly out of scope here.
cargo deny checkpasses with one pre-existing yanked-crate warning,spin 0.9.8, reached throughphonenumberunder relay. It predates thisbranch.
The public docs-site pages for the client library are a follow-up branch,
deliberately. This change updates the docs site only where an existing page
enumerates a file set or a CI job that moved (the spec page's verifier
sentence, the API stability page's job list). It adds no adopter-facing
tutorial or reference page for the client. Each of the three crates carries its
own README, and the client's public API is documented at the item level, so
nothing here is undocumented for someone reading the crate; what is missing is
the site page an adopter would find first.
One version pin cannot inherit from the workspace.
crates/registry-evidence-client-py/Cargo.tomldeclares the SDK under arenamed package, so its version is a literal that needs bumping by hand on
every workspace version change.
Deferred with reasons rather than dropped, all in the client crate: a
backward clock jump can extend a cached credential (closing it needs a
monotonic instant beside the expiry, which interacts with the injectable
Clockthat makes the cache testable); there is no publicinvalidate()onthe token provider, so a caller refused by the resource server cannot force
re-acquisition (the 24-hour clamp on the issuer's stated lifetime bounds the
worst case); the provider sends no
scope,resource, or bodyclient_id,and a scope-gated deployment cannot use it yet; and a string-encoded
expires_infails deserialization.Flagged, deliberately not fixed here
Each of these was found while doing this work or during the whole-branch review
that followed it, and each sits outside what this change owns or is small enough
that fixing it would widen the diff more than it would help. All twenty-nine are
listed; none is rolled up.
crates/registry-manifest-cliescapes the workspace's one security lintwith nothing in its place. It carries no
[lints]table and nounsafe_codedeclaration in eithersrc/lib.rsorsrc/main.rs, so unlikethe Node binding above it is outside
unsafe_code = "forbid"with nosubstitute, and nothing in the repository would notice. Pre-existing. The fix
is one line in that crate's manifest.
wiremock'sset_body_stringsilently overrides an explicitContent-Type. In wiremock 0.6.5 it sets its owntext/plainmime, andgenerate_responseinserts that after cloning explicit headers, so it winsregardless of call order. Two instances in this branch's crate were found and
fixed (they had been passing only because the code under test ignored media
type); the correct construction is
set_body_raw(body, "application/json").Exactly two files in the workspace call it. The other is
crates/registry-evidence/tests/source_contracts.rs, whose six calls set noexplicit content type, so nothing there is misleading today, but all six do
serve
text/plainand would quietly change meaning if Evidence's sourcefetcher ever gained a media-type check. Latent trap, outside this area.
crates/registry-relay/tests/demo_configs_load.rs, gated behind#[cfg(all(feature = "spdci-api-standards", not(feature = "standards-cel-mapping")))], so it does not fire in a default build and didnot fire in any gate run here.
RequestNonce::parseis public API of the client crate with no productioncaller. Either it is intended for relying parties who receive a nonce out of
band, in which case it wants a doc comment saying so, or it is dead public
surface. This is also why neither binding surfaces
NonceError'ssub-discriminant:
NonceError::NotCanonicalis constructed only insideparse, so the production path can only ever produceNonceError::Entropy,and a
nonce_kindattribute would be public surface that can hold one value.Both bindings carry a comment at the mapping arm recording that.
had. The Node crate's README told callers to
JSON.parse(error.message),which stopped being true when the JS layer began reconstructing an error with
prose in
message; a caller following it literally would have hit aSyntaxErroron the ordinary failure path. Fixed here. The crate's drift testkeeps hand-written JS in step with the generated native surface and does that
well, but no equivalent mechanism covers prose. Building one is larger than
this branch should absorb.
tokioinregistry-evidence-client's[dependencies]carries the fullworkspace feature set when only
syncis needed. Left alone on purpose: alocal pin diverging from the workspace entry is exactly the pattern that
produced a reqwest TLS feature-unification surprise during this work.
Trimming it means deciding how this workspace wants per-crate feature
subsetting to work.
mainbumped the crate versions to 0.17.0 but leftv0.16.3URLs indocs/site/src/data/projects.yaml, its generatedprojects.json, andproducts/evidence/CONCEPT.md. Those look like they belong to a releaseprocess rather than a version bump, so nothing in them was touched here beyond
the one
rename_statusline this change owns.panic-containment comment cites
napi-derive 3.6.2when describing what thegenerated glue does for synchronous functions. Accurate today, since the
workspace pins that exact version, and genuinely useful, since the claim is
version-specific, but it will rot silently at the first bump.
crates/registry-evidence-client/tests/against_a_real_deployment.rsholdsthree concerns in one file (verified exchange, token acquisition, deployment
lifecycle). The in-file duplication was factored out; the split was not,
because restructuring a test file mid-branch is churn.
products/evidence/IMPLEMENTATION.mdkeeps a per-file listing, whichdrifts silently: it had already drifted on
mainin four ways before thisbranch touched it, and this is the fifth correction. Replacing the inventory
with a description of the shape would end that, but changing what an approved
document asserts is a governance call.
BTreeSetuniqueness tests that pin stablekind()names force a newmatch arm through exhaustiveness, but nothing forces a new case into the
casesarray, so a variant added with a duplicated string would pass. Thisholds for every such test in the crate including the pre-existing one, so it
is a pattern-level observation rather than a regression.
redacted_debug!; today itis convention.
whether the client crate should reuse
registry-platform-testingrather thancarrying its own harness helpers, or on how
schemarsandutoipashould befeature-gated in the verifier crate.
wiremock'sset_body_bytesnot setting amime, which contradicts wiremock's own documentation for that method. The
assertion holds under either behavior, so it is benign, but it is a test
quietly relying on a library disagreeing with its docs.
__test__/drift.test.jsandtests/python/test_drift.pyassert method,attribute, and exception class names in both directions, never a discriminant
string. That is the right division of labour, since the vocabulary is pinned
exhaustively at its source, but nobody should read the drift tests as
covering it.
SubjectRequestandEvidenceRequestSpecderiveDebug(
crates/registry-evidence-client/src/prepare.rs:51and:65), so theselector profile and the selector field names survive formatting. Selector
values are redacted, since
SelectorValueis inrequest.rs'sredacted_debug!list. The inconsistency is thatrequest.rs:157's testasserts the selector profile does not survive formatting for
EvidenceRequestBody, so two types on one path hold the same value todifferent standards.
value, three in each binding's
convert.rs. They interpolate aserde_json::Errorfromfrom_value, which embeds the unexpected value forscalar mismatches. The affected fields are the expected outputs, the
assurance profile, and the trusted JWKS, none of which carries a credential,
selector value, or binding on the intended path. Sibling sites that
interpolate serialization errors were checked and cleared, as was
PrivateJwk::parse.conversion (
src/convert.rs:161): the fractional part can round to exactly1_000_000_000, which chrono accepts as a leap-second nanosecond rather thanrejecting. At most one nanosecond of skew on a caller-chosen
verifyAsOfinstant, against validity intervals measured in seconds, with no panic path.
hyper, mio, reqwest, rhai, rustix, socket2, tokio, tracing), so a
runtime-shaped dependency not on the list would pass. Its own logic is sound:
--edges normalcorrectly ignores the verifier's dev-only tokio, and itdistinguishes
rgexit 0 from 1 from other, so a broken search fails ratherthan passing silently.
release/scripts/check-release-source-model.shdoes notrequire_paththefour new crates. They are covered by the workspace-wide checks but not
individually asserted to exist the way other areas are.
products/evidence/IMPLEMENTATION.md:518still says "Create the singlecrate and binary", which reads oddly beside the crate tree now that the
verifier is extracted. Scoped to a completed phase, so low risk.
products/evidence/AGENTS.md:16says "after those four product-levelcontracts" and then introduces seven files. Pre-existing on
main,confirmed there before reporting, but it sits in a file this branch edits.
AGENTS.mddescribes a CI job that does not exist. It says "RootCI's
rustjob runs ...", whileci.ymlhasrust-policy,rust-quality,rust-tests, andrust-result, and the Relay OpenAPI drift check itattributes to that job runs in
relay-contracts. Pre-existing, found whilefixing the section immediately below it.
cargo fmt --checkis inrust-quality; clippy and test run through.github/scripts/run_cargo_packages.pyover affected packages rather than--workspace;cargo deny checkis inrust-policy; the three Evidencescripts are in
evidence-contracts. Neither Evidence governance documentmentions the
client-bindingsjob, though the rootAGENTS.mdnow does.jsonschemapin's comment overstates its effect for relay.It says neither the remote-
$refHTTP client nor the CLI parser belongs inthe dependency graph. The
claphalf holds, butcrates/registry-relay/Cargo.tomldepends onreqwestdirectly andunconditionally, so dropping
resolve-httpremoves no crate from relay'sgraph. The behavioral effect is real and now documented and tested; only the
stated rationale is imprecise.
$refclaim may exist. The fixpass owns
crates/registry-relay/docs/configuration.mdonly. If v0.16.xrelease notes or any other page repeated "fails config validation with
spdci.config.schema_compile_failed", that copy is wrong in the same way.vocabulary sweep.
package.jsonandpyproject.tomlare in thesource-product sweep but not the acceptance-vocabulary one, because their
SPDX
licensefield matches thelicen[cs]epattern and would fail the gateon legitimate metadata. A comment in the script records why. Weakening the
pattern was not an option.
fields. Node's equivalent catches an unexpected extra field through the
serialized JSON object's length;
MappedErroris a plain Rust struct with nodirect analogue, and no existing test in that file does an exhaustive-absence
check for any variant, so the file's own convention was followed instead.
products/evidence/scripts/check-source-neutrality.shcarries twopre-existing lint findings: shellcheck
SC1007on itsCDPATH= cdidiom,and one
case-indentation difference undershfmt -i 2. Both arebyte-identical on
main, and neither tool runs in any workflow, so the fixpass left them alone.
DCO
Signed-off-bytrailer. All 45 commits carryexactly one, checked individually.