Skip to content

feat(evidence): harden Evidence and Mint key management - #655

Merged
jeremi merged 7 commits into
mainfrom
codex/key-management-hardening
Aug 6, 2026
Merged

feat(evidence): harden Evidence and Mint key management#655
jeremi merged 7 commits into
mainfrom
codex/key-management-hardening

Conversation

@jeremi

@jeremi jeremi commented Aug 6, 2026

Copy link
Copy Markdown
Member

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 evidencectl workflow, 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 main history and incorporates the verification-policy bounds work from #656.

What changed

  • Add a Unix-socket Vault/OpenBao Transit signer with a pinned key version, exact P-256 public-key matching, bounded responses and timeouts, JOSE signature validation, and startup self-tests.
  • Keep Transit opt-in in registry-platform-crypto, so portable offline verifiers do not inherit networking, Tokio, socket, or tracing dependencies.
  • Move the anchored secret resolver into registry-platform-config and use HKDF-separated audit chain and pseudonym keys in Evidence and Mint.
  • Extend OIDC verification with denied key identifiers and maximum token lifetime enforcement.
  • Move Evidence and Mint service signing to ES256 with derived RFC 7638 thumbprint kids, governed active and published key sets, revocation lists, and provider-aware readiness.
  • Preserve externally owned Mint client-key algorithms and identifiers.
  • Upgrade the Rust, Node.js, and Python Evidence clients and portable verifier for ES256 service keys, P-256 holder keys, current revocation lists, and cached-key revocation precedence.
  • Refuse verification-policy lifetime, clock-skew, and list-cardinality bounds outside the Version 1 contract.
  • Tighten SD-JWT VC holder binding to public P-256 JWKs and pin the profile to RFC 9901 and SD-JWT VC draft v18.
  • Update Evidencectl key generation, scaffolding, development, build, doctor, and production handoff workflows.
  • Add complete local, staging, and production target examples, Transit proxy configurations and policies, key-separation checks, rotation procedures, and operator documentation.
  • Restrict Transit sign policies to the exact request parameters and configured key versions, with an explicit two-version overlap procedure for planned rotation.
  • Update Version 1 contracts, schemas, fixtures, generated OpenAPI, Compose examples, client examples, and public documentation.

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

  • Evidence signing, Mint signing, audit, subject binding, holder, and client key roles remain distinct.
  • Transit tokens and auto-auth credentials stay outside Evidence and Mint. Runtimes communicate only through workload-local Unix sockets.
  • Provider metadata, pinned versions, non-exportability controls, public keys, and sign-and-verify behavior are checked before readiness.
  • Transit ACL parameter constraints restrict the service identity to the reviewed JWS request shape and permitted key versions.
  • Revoked identifiers take precedence over cached JWKS selection and stale prepared-policy state.
  • Audit-key rotation starts a new epoch and never appends a replacement key to an existing chain.
  • Git-managed examples contain public keys and nonsecret provider configuration only.
  • Final independent security and documentation reviews found no remaining actionable issues.

Verification

  • cargo fmt --check
  • cargo check --locked --workspace --all-targets
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --locked --workspace
  • cargo deny check
  • Crypto tests with and without the transit feature
  • Rust, Node.js, and Python Evidence client tests
  • Evidence contract, source-neutrality, and verifier-portability checks
  • Generated OpenAPI drift checks
  • Docs npm test and npm run check
  • Deployment-target key-separation tests
  • git diff --check

The opt-in Inji and walt.id compatibility scripts were not run. This PR makes no third-party interoperability claim.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.52271% with 30 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/registry-platform-crypto/src/lib.rs 97.22% 21 Missing ⚠️
crates/registry-platform-audit/src/lib.rs 87.67% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

jeremi added 7 commits August 6, 2026 15:27
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>
@jeremi
jeremi force-pushed the codex/key-management-hardening branch from 809d21b to da1655d Compare August 6, 2026 09:40
@jeremi
jeremi marked this pull request as ready for review August 6, 2026 11:00
@jeremi
jeremi merged commit 504de5f into main Aug 6, 2026
43 checks passed
@jeremi
jeremi deleted the codex/key-management-hardening branch August 6, 2026 11:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +381 to 385
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?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant