Skip to content

feat(evidence): accept wallet-presented credentials as an acquisition channel - #2577

Merged
jeremi merged 1 commit into
mainfrom
feat/evidence-wallet-m1
Nov 7, 2026
Merged

jeremi merged 1 commit into
mainfrom
feat/evidence-wallet-m1

Conversation

@jeremi

@jeremi jeremi commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Evidence can answer a definition from a credential the holder presents from a wallet, beside the existing authoritative-source channel. A selector profile selects the channel, every assertion states it in a signed channel member, and relying parties accept source only unless they opt in. The wallet-facing OpenID4VP 1.0 exchange runs in a separate supporting service, registry-evidence-oid4vp; every credential check runs in Evidence through the pure registry-evidence-presentation crate, so a compromised verifier service can withhold or misroute a presentation but cannot forge one. The PR also lands the shared-code cleanup across products that the change made necessary.

What changes

Evidence

  • acquisition.holderPresented channel and the built-in wallet-presentation-v1 host protocol (SD-JWT VC, holder binding always required, issuer allow-list and deny-list, maximum credential age, possession or attribute-match subject link). Behind the default wallet-presentation feature.
  • BREAKING: registry.assertion-evidence/v1 gains the required signed channel member, and holder-presented assertions carry presentedCredential. Payloads signed before this change are refused as malformed.
  • Verification policies gain acceptedChannels (default [source]) and maximumCredentialAgeSeconds; the Rust client and the Node and Python bindings expose both.
  • Discovery keeps one entry per handle; a caller entitled to both channels gets an additive alternatives member, and a caller entitled to the source channel alone sees exactly the source-only entry.
  • normalise_name(profile, value) Rhai primitive with the single profile name-match-v1, for one exact comparison against one expected value. It folds case, Latin, Greek and Cyrillic accents and the optional Arabic and Hebrew vowel marks, and keeps the marks that are part of a spelling (Devanagari virama, nukta and anusvara, Thai tone marks), so distinct names never compare equal.
  • A holder-presented definition answers only as a signed JWS assertion (SD-JWT VC and unsigned JSON are refused at startup and by evidencectl), and its maximumCredentialAgeSeconds may be up to ten years, measured from the credential's iat.
  • Issuer half: optional signing.certificateChainFile (x5c in SD-JWT VC headers and the JWKS), and an optional OpenID4VCI Draft 13 compatibility profile in evidence-oid4vci.

registry-evidence-oid4vp (new supporting service)

  • OpenID4VP 1.0 with the HAIP 1.0 verifier flow profile (direct_post.jwt), four client identifier types, memory-only sessions with a byte budget, one canonical traceparent, a closed problem vocabulary under https://id.registrystack.org/problems/registry-evidence-oid4vp/, OpenAPI and runtime schema in products/evidence/generated/.
  • A held result is released to Evidence once: the first successful read consumes it.
  • Not certified. No wallet is claimed as supported until a reviewed device-run receipt exists; the CLI is listed as unreviewed and source-build only.

Shared code (one implementation per concern)

  • Access-token algorithm roster (registry-platform-oidc), fail-closed stop-signal handling, Prometheus exposition, compact JWS parsing (registry-platform-crypto), one XChaCha20-Poly1305 sealing primitive with dual-read of earlier records, framework rejection normalisation, and small helpers, adopted across Casework, Coordinator, Messaging, Document Store, Scheduling, Render, Discovery, Adapter, MCP, BReg and the Evidence family. Behaviour changes are in each product's CHANGELOG.
  • evidence-oid4vci: shared security headers, bounded bodies, rateLimits with 429 (offers budgeted per principal digest, wallet endpoints per endpoint), framework rejections as JSON, and a token form that cannot be decoded answered 400 invalid_request.
  • Platform gains registry-platform-text, registry-platform-metrics and registry-platform-x509; registry-mcp and breg-mcp refuse startup when a stop-signal handler cannot be installed instead of waiting on a signal they cannot observe.
  • BReg: blind-index lookups gain nfc and case-fold (NFC, full case fold, then NFC) steps, so composed and decomposed spellings of one name share a digest and a unique lookup no longer accepts both. Changing only a lookup's normalization list becomes a reviewed, resumable re-index step that re-derives digests from the stored envelopes. Every field-encryption check (unique collisions, the maxTotalRows ceiling, entity and table binding, flip duplicates) runs before the apply commits anything, so a refused target is revertible; once the first chunk commits, reconcile reports the target unresolvable and retrying the same apply resumes it. Authored migration SQL may not write a blind-index column. bregctl field-encryption preflight scans re-index steps and holds a table lock for the scan, so it belongs in a quiet window. Governed-action Evidence acceptance requires channel: source.

Breaking and behaviour changes (pre-1.0)

  • Assertions without channel are refused; reissue them and regenerate golden fixtures.
  • Framework rejections in evidence-oid4vci are JSON; 422 on the token form became 400.
  • A stop-signal handler that cannot be installed refuses startup in the adopting binaries.
  • Rust API only: registry-platform-sdjwt no longer exports the Evidence holder-proof profile (moved to registry-evidence-verifier); registry-mcp-edge re-exports the shared AccessTokenAlgorithm, whose jsonwebtoken() is now to_jsonwebtoken().
  • See the Evidence, Platform, Adapter, BReg, Discovery, Render, Casework, Coordinator, Messaging, Document Store and Scheduling CHANGELOGs.

Security review notes

This change touches authentication, assertion evaluation and signing, disclosure, audit and deployment defaults. Four independent read-only reviews ran on the branch: the wallet protocol service and its crypto, the holder-presented channel in Evidence, the shared-code cleanup, and the BReg blind-index range. None found a blocker, and each finding below was fixed and re-verified. Fixed in this PR:

  • The evidence-oid4vci offer limiter keyed its bucket on the raw principal, which the limiter refuses when it holds whitespace or passes 256 bytes, so an authorized client such as Adopter Portal got 429 on every offer with no log (medium, a regression from the cleanup). The key is now a domain-separated digest of the principal and its kind, and a limiter fault is logged and answered 500 without Retry-After.
  • One presentation could be read repeatedly within the result window, so an attribute-match check became a name-guessing oracle (medium). The verifier now consumes a result on its first successful read; the composed gate proves a second name against a consumed session is refused.
  • Evidence bounds the verifier-stated receipt time (later than the read plus skew, or earlier than the largest result window plus skew, is refused), so a compromised verifier cannot replay a kept presentation beyond that window.
  • An acceptance audit record could say "accepted" while the wallet was refused at the window edge; a reservation is now confirmed regardless of the window.
  • /ready fails once a client identifier certificate expires.
  • BReg case-fold folded before composing, so a lookup declared [case-fold] alone gave two canonically equivalent spellings two digests and a unique lookup could hold both (integrity, unreleased). It now composes first.
  • Authored migration SQL could write a blind-index column beside an engine re-index or an encryption flip, leaving a digest no lookup of the stored value matches. Such a plan is now refused (migration.review.blind-index-write-refused).
  • name-match-v1 dropped every nonspacing mark, so Devanagari names differing only by a virama, nukta or anusvara compared equal: a false subject match on a hard field. It now keeps those marks.
  • attribute-match with no disclosed claim is refused at startup; discovery no longer reveals that a wallet channel exists to a source-only caller; a misassigned channel Source is refused at startup naming the member to repair.
    Known limits, documented in known-limitations and the threat model: with a cross-device flow a holder can present into a session someone else started (possession proves a trusted credential was presented, not who presented it); the presenting principal is stated by the caller (one relying party per Evidence deployment for wallet channels); the wallet-facing limiters of both supporting services bound each endpoint as a whole, so one source can spend a budget and per-source rate limiting belongs at the ingress.
    New negative tests are registered in products/evidence/contracts/security-test-traceability.yaml and bound to security-invariant-matrix.yaml.

Evidence (checks run)

The branch is one signed-off commit on top of current main, squashed so the rebase merge queue can take it; the local checks below ran on the pre-squash head, whose tree differs only in Cargo.lock: merging main had taken main's yanked crypto-bigint 0.7.6, and the squash restores the branch's 0.7.10 (every dependent asks for ^0.7.5; cargo deny check and a check of the crypto consumers pass on it).

CI (ci:full) is green on the branch head: all 65 jobs that run on a pull request pass, including every Rust test lane, every BReg PostgreSQL lane, the Evidence PostgreSQL suites, the Evidence tutorials, the BReg to Evidence composition proof, the native client bindings, and the editor extensions. Run locally on the branch head, current with main, unless a line says otherwise.

  • cargo fmt --check; clippy -D warnings on the whole workspace, and on the Evidence family without default features.
  • Tests: registry-evidence 1155, authoring 248, registry-evidence-oid4vp 139, registry-evidence-oid4vci 315, registry-evidencectl 973, registry-evidence-verifier 175, registry-platform-text 56; the ignored gates evidence_journey and device_run (simulated wallet) pass.
  • PostgreSQL, run before the last merge of main (which brought the deterministic Rhai base and the docs cut, and no storage change): Evidence status ledger, transitions and privacy (47), OID4VCI store_contract (36); BReg test-postgres.sh lane by lane as CI shards it, postgres:1/3 to 3/3 and immediate-actions (1217, including the operator proof, the Evidence provider proof and the action-evidence trio); BReg native timer replicas against a server with 400 connections (29).
  • CI defers the fuzz smoke to the merge queue and line coverage to main, so both were run locally on the branch head: all 13 platform and Evidence fuzz targets for 60 s each on the host target with the CI libFuzzer limits (no crashes), and cargo llvm-cov over registry-platform-* with all features (90.54% of lines against the 80% floor).
  • Four timing-bound tests in code this PR does not change each failed once on a shared, heavily loaded machine and passed on rerun: proposal_turn_filter_pages_a_bounded_scan_without_gaps_or_duplicates (its 2 s action deadline), activation::in_flight_predecessor_answer_is_terminal_after_activation (a lease settled late), and native_database_session_loss_rolls_back_active_transaction_for_peer_recovery (run against a 100-connection server; it passes against 400); and the Adapter engine's evidence_attempt_defaults_and_overrides_complete_after_ten_seconds, which pauses tokio time across a real socket round-trip (10 of 10 isolated reruns pass).
  • The new oid4vp test certificate_expiry_logs_one_warning_without_certificate_content failed about 1 run in 40 under load. It captured logs with a thread-scoped subscriber, and tracing caches a callsite as disabled when a parallel test with no subscriber reaches it first. It now runs in its own test binary under the process-wide subscriber (0 failures in 990 loaded runs); the service code is unchanged.
  • BReg: registry-breg, registry-bregctl, registry-breg-mcp and registry-breg-review tests (2905); products/breg/scripts/check-contracts.sh (133). Coordinator and its client (709), dependency direction and schemas. The Rhai consumers registry-platform-script, registry-platform-hooks, registry-adapter-engine and registry-messaging (811).
  • Evidence check-contracts, check-source-neutrality and its self-test (now scanning the oid4vp and presentation crates and their manifests), check-verifier-portability, check-config-key-paths, check-authoring-schema, check-authoring-no-io; identifier check.sh and --check-references; dependency-direction checks for six products; config conformance; registry-release validate-current and validate-docsets; cargo deny check; Node client sync; ci_changes tests; docs npm test (948) and npm run check; gitleaks dir with the repository config over the committed tree (0 findings).
  • CodeQL (not a required check) reports 8 rust/hard-coded-cryptographic-value alerts on this PR, all triaged as not reaching production: seven are fixed keys, nonces and golden vectors inside #[cfg(test)] modules of xchacha_seal.rs, the OID4VCI sealing.rs and the BReg cursor.rs (the golden vectors pin the stored form deployed databases already hold); the eighth (xchacha_seal.rs:57) is a zeroed nonce buffer that aws_lc_rs::rand::fill overwrites before use, and the fixed-nonce helper it feeds is private and reached only from seal and tests. All 8 are dismissed with this triage.
  • CI gains a build of registry-evidence without wallet-presentation, and runs the composed journey and the simulated device run.

Not in this PR

  • mdoc, status checking for presented credentials, the same-device response_code flow, a typed channel picker in the Node and Python bindings, and a session-bound reader for multi-relying-party deployments.
  • BReg: blind-index digests are not tied to the Unicode data version (a test pins the version so a bump fails loudly); authored DDL on a blind-index column fails at activation's catalog check rather than at review; authored writes to an encrypted field's envelope column are not refused at review (a copied envelope fails its record-bound AAD).
  • Casework has no stop-signal handling of its own; the metrics output of Casework, Coordinator, Messaging, Render and Adapter has no golden scrape (Evidence and OID4VCI do).
  • Real-wallet device runs: the receipt format and harness are here; receipts come from a person with a device.

DCO

  • Every commit includes a Signed-off-by trailer.
  • I reviewed the submitted changes and am responsible for the contribution.

@jeremi jeremi added the ci:full Run the heavy integration tier on this pull request label Nov 6, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T04:47:35.239492Z 27b0b3f New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread crates/registry-breg/src/cursor.rs Dismissed
@jeremi
jeremi force-pushed the feat/evidence-wallet-m1 branch from bbdbe73 to 0ed97eb Compare November 7, 2026 04:02
… channel

Evidence can answer a definition from a credential the holder presents from
a wallet, beside the authoritative-source channel. A selector profile selects
the channel, every assertion states it in a signed `channel` member, and
relying parties accept `source` only unless they opt in through
`acceptedChannels`. The wallet-facing OpenID4VP 1.0 exchange (HAIP 1.0
verifier flow) runs in the new supporting service `registry-evidence-oid4vp`;
every credential check runs in Evidence through the pure
`registry-evidence-presentation` crate, so a compromised verifier service can
withhold or misroute a presentation but cannot forge one. A held result is
released to Evidence once.

Evidence gains the `wallet-presentation-v1` host protocol (SD-JWT VC, holder
binding, issuer allow and deny lists, maximum credential age, `possession` or
`attribute-match` subject link), the `name-match-v1` Rhai normalisation
profile, `x5c` certificate chains on the issuer half, and an OpenID4VCI
Draft 13 compatibility profile in `evidence-oid4vci`.

Shared code moves to one implementation per concern across the products:
access-token algorithms, fail-closed stop-signal handling, Prometheus
exposition, compact JWS parsing, XChaCha20-Poly1305 sealing with dual-read of
earlier records, and framework rejection normalisation. Platform gains
`registry-platform-text`, `registry-platform-metrics` and
`registry-platform-x509`.

BReg blind-index lookups gain `nfc` and `case-fold` steps, a reviewed,
resumable re-index for a changed normalization list, field-encryption checks
that run before the apply commits, and a refusal of authored migration SQL
that writes a blind-index column. Governed-action Evidence acceptance
requires `channel: source`.

BREAKING: `registry.assertion-evidence/v1` requires the signed `channel`
member; assertions without it are refused. `evidence-oid4vci` answers
framework rejections as JSON, and an undecodable token form as 400. A
stop-signal handler that cannot be installed refuses startup in the adopting
binaries. Each product's CHANGELOG states its changes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi force-pushed the feat/evidence-wallet-m1 branch from 0ed97eb to 27b0b3f Compare November 7, 2026 04:45
@jeremi
jeremi added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit 5a971ed Nov 7, 2026
79 checks passed
@jeremi
jeremi deleted the feat/evidence-wallet-m1 branch November 7, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Run the heavy integration tier on this pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants