feat(evidence): harden Evidence and Mint key management - #655
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`maximumAssertionLifetimeSeconds` and `clockSkewSeconds` are the only two verification policy constraints that fail open: honouring a value wider than the contract allows makes the verifier accept assertions a conformant relying party must refuse. Nothing enforced them, so a policy document could state a bound the contract forbids and the verifier honoured it. Every route to a policy now refuses those two bounds instead of honouring them, and the enforced numbers are pinned to the contract by a test that reads `verification-policy.schema.yaml`. Security review notes: - Three routes to an unbounded policy are closed: deserializing an `EvidenceVerificationPolicyDocument` (per-field `deserialize_with`), converting one to a policy (`into_policy` becomes `try_into_policy`), and re-verifying a retained response (`from_accepted_transaction` now takes seconds and returns a `Result`). The fourth route, an external caller building `EvidenceVerificationPolicy` by struct literal, is closed by making the two time fields private with `maximum_assertion_lifetime()` and `clock_skew()` accessors. - A forbidden bound is refused as an unusable input document, never reported as a verification outcome. The failure-class vocabulary in the contract is frozen and has no class for an unusable policy, and the contract already assigns exit 1 to a malformed input. `evidence verify --policy` therefore refuses with the existing malformed class. - No deployment can regress into the new refusals. The bundle schema bounds `validitySeconds` to 1..=31536000 and `verifierClockSkewSeconds` to 0..=300, so a loaded deployment cannot reach a policy the contract forbids; the client SDK's `prepare` bounds the same two expectations, so the refusal in `client.rs` is unreachable from a prepared request. Both stay refusals rather than assumptions. - Only these two bounds are enforced. Every other policy constraint fails closed, because the payload is itself contract-checked, so an out-of-contract expectation is one no conformant payload can match. - One surface stays unguarded by design: `Serialize` on the document will write out a forbidden bound for a document built in code. Nothing honours such a document, because every reader and every conversion refuses it. - The client SDK's duplicated lifetime and skew constants are deleted in favour of the verifier's, so there is one source of truth for the numbers. Tests: contract-pinned bounds, refusal when a document is read, acceptance at both bounds, refusal for a document built in code, refusal for an accepted transaction, and an operator-boundary `evidence verify --policy` case for all three out-of-contract values. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
809d21b to
da1655d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: da1655daad
ℹ️ 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".
| let profile = AuditProfile::production_from_secret_bytes(Zeroizing::new(master_secret))?; | ||
| let chain_hasher = profile.chain_hasher(); | ||
| let key_hasher = profile.key_hasher(); | ||
| let sink = Arc::new( | ||
| DurableSegmentedAuditLog::initialize(path, maximum_file_bytes, chain_hasher).await?, |
There was a problem hiding this comment.
Bind hashKeyVersion to the audit epoch
When an operator increments audit.hashKeyVersion but accidentally reuses the existing master and audit path, initialization derives the same chain and identifier keys, successfully verifies the old tail, and then appends records labeled with the new version. This contradicts the new audit_key_epoch invariant, which requires any version change to start a fresh path, and creates one apparent chain spanning two nominal epochs; include the version in the epoch binding or inspect the existing tail and refuse a mismatch. The parallel Mint initialization has the same gap.
AGENTS.md reference: products/evidence/AGENTS.md:L82-L88
Useful? React with 👍 / 👎.
Summary
Hardens the unreleased Evidence and Mint Version 1 signing contracts and implementation around ES256/P-256, RFC 7638 thumbprint key identifiers, non-exportable Vault/OpenBao Transit signing, explicit key publication and revocation, bounded token lifetimes, and separated audit keys.
The change also upgrades the Rust, Node.js, and Python Evidence clients to the new service-key contract, preserves the local
evidencectlworkflow, adds complete Git-managed environment targets without secrets, and keeps SD-JWT VC output ready for future standards-based wallet interoperability without adding wallet or OID4VCI integration.This branch is rebased on the post-#649
mainhistory and incorporates the verification-policy bounds work from #656.What changed
registry-platform-crypto, so portable offline verifiers do not inherit networking, Tokio, socket, or tracing dependencies.registry-platform-configand use HKDF-separated audit chain and pseudonym keys in Evidence and Mint.kids, governed active and published key sets, revocation lists, and provider-aware readiness.Developer and operator impact
This is an intentional pre-1.0 breaking configuration and client revision. Existing Evidence and Mint service-signing configuration must move to the active, published, and revoked public-key model plus the signer union. Production and evidence-grade deployments require Transit. Supervised local development continues to work with automatically generated local P-256 keys.
Relying applications must provide a current service-key revocation list independently of the response and issuer-published JWKS. Current revocations override cached keys and older prepared-request policy state.
Configuration remains startup-only. No control plane, hot reload, automatic promotion, cloud KMS abstraction, wallet protocol, DID resolution, or Inji-specific behavior is introduced.
Security review notes
Verification
cargo fmt --checkcargo check --locked --workspace --all-targetscargo clippy --workspace --all-targets -- -D warningscargo test --locked --workspacecargo deny checktransitfeaturenpm testandnpm run checkgit diff --checkThe opt-in Inji and walt.id compatibility scripts were not run. This PR makes no third-party interoperability claim.