Skip to content

Commit 34b280c

Browse files
jeswrclaude
andauthored
feat(zk): authenticate the commitment method from the issuer signature (#7082)
* feat(zk): authenticate the commitment method from the issuer signature The verifier no longer has to assume which commitment method a committed graph was made under, and it does not read one from the manifest either -- it RECOVERS it from the issuer signature. `sparq-zk-compose::method_auth` builds one candidate signed message per `CommitmentMethod::recognised()` method from values the verifier itself established (the commitment, the issuer-attested salt, the issuer-bound status reference, and the attested holder digest when the attestation is holder-bound) plus the method-UNBOUND message, and `classify` reports which one the presented signature verifies over. Exactly one bound candidate verifying names the method; more than one is refused rather than resolved. The prover only SELECTS among messages the verifier computed, so it contributes no message of its own. `MethodPolicy` is the relying party's decision over the answer, carried on the external trust anchor (`KeySet::with_method_policy`, the `with_hidden_issuer_depth` precedent, so no public verifier signature changes). The default accepts a method-unbound attestation, which is what makes this back-compatible: an existing manifest's `ZKSIG_C3`/`ZKSIG_C4` attestation authenticates as `Unbound`, the recomputed message is byte-identical to the one the verifier computed before, and a bad signature still reports `InvalidIssuerSignature`. New in `sparq-zk`: the `ZKSIG_M4` message (the one shape binding both a holder key and a method) with its two signers, `fragment_typed_filters` for a datatype-general view of a query's FILTER obligations, and `CommitmentMethod::recognised()`. `sparq-zk-compose::value_pin` (behind the OFF-by-default `dual-leaf` feature) states what a value-lane sub-proof's member, lane constant, scale, op, bound and verdict would have to be for an authenticated `DualLeafV2` method and a typed query FILTER. **The value-lane verifier gates stay CLOSED:** the unconditional stage-0 refusal is unchanged and still first, the stage-2 operand match and `filter_edge_true` are untouched, and a manifest every one of whose pins holds is still refused (a test asserts exactly that). Computing a pin is not enforcing one. Hardening only. Not externally audited (sq-qhy4); no soundness or privacy property is claimed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: fix extended-fragment test build, test hidden-only method policy, register ZKSIG_M4 - The three extended-fragment prefilter tests read Prefiltered.required. - A hidden-only commitment is tested against the method policy: an attested V1 message is refused by default and admitted with the opt-in; an unbound message is refused under require_method_bound. - ZKSIG_M4 moves to module scope and joins the pinned domain-tag table. - The re-spelled lane-constant test keeps a decodable 0x prefix so only the canonical-spelling check can refuse it. - Research note separates the enforced method check from the computed-only pins; CHANGELOG states the back-compat claim precisely. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: separate the presentation message from the registry one, hold the hidden-only method to the policy Three hardening changes to verifier-side commitment-method authentication. 1. The relying party's `MethodPolicy` now applies to the method the hidden-issuer stage actually binds. A hidden-only commitment's declared `message` must EQUAL one of the verifier-built candidate messages: one matching none of them is refused in the pre-filter with `HiddenIssuerMessageMismatch` (the same refusal the bb stage's own byte-compare raises), and every hidden-only commitment the attestation gate accepts now carries an explicit entry in the method map, so the absent-key default is not what decides its bound message. Where no candidate set can be built at all, the policy is applied to `Unbound`. 2. Domain separation between a REGISTRY record and a PRESENTATION attestation. `sig::presentation_message_with_status_and_scheme` adds the `ZKSIG_M5` tag (same preimage shape as the registry's `ZKSIG_M3`, own tag), signed by `SecretKey::sign_presentation_with_status_and_scheme`; the bearer candidate set on the clear and hidden paths uses it, `ZKSIG_M4` still covers the holder-bound axis and `ZKSIG_C3`/`ZKSIG_C4` remain the method-unbound candidates. Registry signing (`ZKSIG_M2`/`ZKSIG_M3`) is unchanged, and a registry signature presented as a clear attestation reports `InvalidIssuerSignature`. `CommitmentAttestation::from_registry_entry` is removed: a registry entry is not a presentation attestation. 3. Candidate trial-verification is memoized per (commitment, attestation index) within one attestation-gate call, and per commitment on the hidden-only path, so a commitment many scans reference is verified once. Every other check in the loop still runs on every occurrence; `classify` keeps no early exit. Docs follow the code: who chooses what on each path, that a hidden-only commitment's recorded method is the one its declared message selects and the bb stage then holds it to, and an accurate back-compatibility statement (every manifest that verified before still verifies under the default policy; the newly accepted ones are those signed over a method-bound presentation message). Not externally audited (sq-qhy4). Tests: `ZKSIG_M5` wire-definition KAT and a registry-signature-is-not-a- presentation pair in `sig.rs`; the same split at the candidate-set level and through the pre-filter; the unmatched hidden-only message refused under both policies, structurally and live end to end over a real hidden-issuer proof. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: refuse an undecodable hidden-only message once a candidate set exists Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: check every hidden-issuer entry's declared message, not just the first `authenticate_hidden_only_method` read only the FIRST hidden-issuer entry declared over a hidden-only commitment, so the verdict depended on the order the prover listed its entries in: [honest, unmatched] passed the pre-filter while [unmatched, honest] was refused. Every entry over that commitment is now checked — each must decode and equal a verifier-built candidate, and all of them must select the SAME candidate, else HiddenIssuerMessageMismatch. The bb stage already byte-compares every entry against a recomputed message, so this removes the order dependence without widening what is accepted. Tests (sparq-zk-compose, tests/method_auth_binding.rs): - every_hidden_entry_declared_message_is_checked_in_either_order — the two orders above, plus two entries that each match a candidate but different ones, in both orders. - each_hidden_only_commitment_gets_its_own_policy_decision — two distinct hidden-only commitments under require_method_bound, the first declaring the M5 message and the second the unbound one: MethodNotAuthenticated. - a_hidden_only_commitment_without_a_status_reference_is_refused_when_a_method_is_required — no candidate set, so the policy decides on Unbound, through the pre-filter. - live_additive_clear_m5_attestation_plus_a_hidden_proof_over_the_m5_message (#[ignore], nargo/bb) — a clear M5 attestation alongside a hidden proof over that same message still verifies end to end under the strict policy. - sparq-zk sig.rs: M5 and M4 hex known answers alongside C2/C3/M2/M3. Docs: qualify the back-compat statement in CHANGELOG.md and skills/zk-query-proofs/SKILL.md (the declared message was previously not read; a hidden-only manifest whose declared message is absent, non-canonically spelled or unmatched is now refused, and a refusal may be reported earlier, in the pre-filter); correct the M3/M4 family note in sparq-zk-compose/src/lib.rs to M5/M4; place M4 among the method-bound shapes and note in the M5 rationale that a registry entry signed before the method binding carries a C3 signature indistinguishable from a C3 presentation attestation (accepted as Unbound by default, excluded by require_method_bound); state the additive-mode behaviour on HiddenIssuerAttestation::message; correct Prefiltered::methods on a hidden-only commitment with no resolvable salt. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: scope the hidden-message docs to what is checked, tighten two tests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: check every hidden-issuer entry's declared message against the message its proof is checked against The hidden-issuer stage now refuses, before decoding any proof, an entry whose declared message differs from the recomputed one, including over a commitment that also has a clear attestation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo * zk: keep the depth test's doc on the depth test; state the declared-message qualification precisely Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f7bdc0 commit 34b280c

15 files changed

Lines changed: 5173 additions & 105 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **sparq-zk-compose:** verifier-side **authenticated commitment method**. The verifier no longer has
13+
to assume which commitment method a committed graph was made under, and it does not read one from
14+
the manifest either — it RECOVERS it from the issuer signature. `method_auth` builds one candidate
15+
signed message per `CommitmentMethod::recognised()` method from values the verifier already
16+
established (the commitment, the issuer-attested salt, the issuer-bound status reference, and the
17+
attested holder digest when the attestation is holder-bound) plus the method-UNBOUND message, and
18+
`classify` reports which one the presented signature verifies over: `AuthenticatedMethod::Bound(m)`
19+
or `AuthenticatedMethod::Unbound`. `MethodPolicy` is the relying party's decision over the answer,
20+
carried on the external trust anchor as `KeySet::with_method_policy` (the
21+
`with_hidden_issuer_depth` precedent, so no public verifier signature changes):
22+
`MethodPolicy::current()` is the default, `require_method_bound()` refuses an attestation that binds
23+
no method (`CheckError::MethodNotAuthenticated`), and `admit_legacy_dual_leaf_v1()` is the
24+
written-down opt-in for an attested superseded method (refused otherwise with
25+
`CheckError::MethodNotAdmitted`); more than one method's message verifying is
26+
`CheckError::AmbiguousMethod`. For a HIDDEN-ONLY commitment the method is selected by the hidden
27+
entry's declared `message`, which must EQUAL one of the candidates — a value matching none of them
28+
is refused with `CheckError::HiddenIssuerMessageMismatch`, and the policy applies to the method the
29+
hidden-issuer proof is then required to bind. Every entry over a hidden-only commitment is checked,
30+
and they must all select the same candidate, so the message check does not depend on the order
31+
the entries are listed in. New PRESENTATION message shapes and signers in `sparq-zk`, each under its own
32+
domain tag: `sig::presentation_message_with_status_and_scheme` (`ZKSIG_M5`, for a bearer
33+
attestation) and `sig::commitment_message_with_holder_and_scheme`
34+
(`ZKSIG_M4`, the one shape binding both a holder key and a method), signed with
35+
`SecretKey::{sign_presentation_with_status_and_scheme, sign_commitment_with_holder_and_scheme}`. A
36+
REGISTRY entry's own `ZKSIG_M3` signature is deliberately NOT a presentation attestation — recording
37+
a commitment in an issuer's registry is a separate statement from authorising its presentation, so
38+
presenting one as a clear attestation reports `CheckError::InvalidIssuerSignature`. Back-compatible
39+
with ONE qualification, about the declared `message` of hidden-issuer entries: a hidden-issuer entry's declared `message` was previously not read at all,
40+
and over a hidden-only commitment it is now required to equal a verifier-built candidate (for every
41+
entry over that commitment), so a hidden-only manifest whose declared message is empty,
42+
not decodable as a field element, or unmatched is now refused with `CheckError::HiddenIssuerMessageMismatch`;
43+
a refusal of a malformed hidden-only manifest may also be reported earlier, in the pre-filter, than
44+
it was before. The hidden-issuer stage likewise requires EVERY entry's declared `message`, including
45+
one over a commitment that also has a clear attestation, to equal the message its proof is checked
46+
against, and that check may now be reported ahead of an entry's depth or proof refusal. Otherwise
47+
every manifest that verified before still verifies under the default
48+
policy — an attestation over the method-unbound `ZKSIG_C3`/`ZKSIG_C4` message authenticates as
49+
`Unbound`, which the default policy accepts, and a bad signature still reports
50+
`CheckError::InvalidIssuerSignature`. The newly accepted manifests are only those carrying an
51+
issuer signature over a method-bound PRESENTATION message (`ZKSIG_M5`/`ZKSIG_M4`). Not externally
52+
audited (sq-qhy4); no soundness or
53+
privacy property is claimed.
54+
- **sparq-zk:** `verify::fragment_typed_filters` re-derives a query's FILTER obligations keeping each
55+
constant as the typed `oxrdf::Literal` the query spells (`verify::TypedQueryFilter { variable, op,
56+
constant }`), for a relying party that needs the constant's datatype and lexical form rather than a
57+
`u64`. It shares one private walker with `fragment_filters`, whose behaviour and error strings are
58+
unchanged (a regression test asserts the messages verbatim), and widens the datatype axis only: the
59+
admitted comparison shape is still `?var op <literal>` after the `const op ?var` flip, with
60+
everything else fail-closed. Also `commit::CommitmentMethod::recognised()`, the enumeration
61+
counterpart of `from_scheme_iri`.
62+
- **sparq-zk-compose:** `value_pin` holds the relying party's **pinned value-lane inputs** for the
63+
dual-leaf lanes (OFF-by-default `dual-leaf` feature): given an authenticated `DualLeafV2` method and
64+
a typed query FILTER, `expected_value_lane` / `check_value_lane_pin` state what a value-lane
65+
sub-proof's member, lane constant (for the decimal lane, the scale it names), op, bound and verdict
66+
would have to be, each equality failing closed with its own `PinError`; `scan_method` requires every
67+
commitment a scan draws from to have authenticated the same method, and `check_lane_routed` refuses
68+
a lane the whole-graph commitment builder does not route. **The value-lane verifier gates stay
69+
CLOSED:** the unconditional stage-0 `CheckError::ValueLaneSubProofNotAccepted` refusal is unchanged
70+
and still first, the stage-2 binding-edge operand match and `filter_edge_true` are untouched, and a
71+
manifest every one of whose pins holds is still refused (a test asserts exactly that). Computing a
72+
pin is not enforcing one; the gate-opening change is separate. Research-grade, not externally
73+
audited (sq-qhy4); the value lanes carry the #769-accepted INV-VL downgrade (CR-G8).
1274
- **sparq-zk:** `dual_leaf_v2` is a second dual-leaf commitment method (`CommitmentMethod::DualLeafV2`,
1375
scheme IRI `https://sparq.dev/ns/zk#poseidon2-dualleaf-v2`) behind the OFF-by-default `dual-leaf`
1476
feature. It keeps V1's leaf shapes and re-derives the constants: lane constants in BLAKE3's

‎crates/sparq-zk-compose/src/dispatch.rs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -584,10 +584,13 @@ fn value_lane_field_hexes(
584584
///
585585
/// PRECONDITION, stated plainly: this is a HOST-side boundary on what this crate
586586
/// builds, renders and proves, plus a structural pre-filter refusal verifier-side.
587-
/// The value-lane verifier GATES (`verifier.rs`'s stage-2 binding edges and
588-
/// `filter_edge_true`) stay closed until the verifier authenticates the commitment
589-
/// method and pins member, lane, scale and bound itself; a check here does not
590-
/// make a value-lane proof verifier-enforced.
587+
/// The verifier now does AUTHENTICATE the commitment method from the issuer
588+
/// signature ([`crate::method_auth`]) and can compute the member, lane, scale and
589+
/// bound a value-lane sub-proof would need ([`crate::value_pin`]) — but the
590+
/// value-lane verifier GATES are still CLOSED: `verifier.rs` refuses every
591+
/// value-lane sub-proof unconditionally at stage 0, and its stage-2 binding edges
592+
/// and `filter_edge_true` are unchanged. Neither a check here nor a computed pin
593+
/// makes a value-lane proof verifier-enforced.
591594
#[cfg(feature = "dual-leaf")]
592595
pub fn check_lane_const_for_member(
593596
circuit: &CircuitId,

‎crates/sparq-zk-compose/src/lib.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ pub mod value_lane;
4747
pub mod holder;
4848
pub mod issuer;
4949
pub mod manifest;
50+
// Verifier-side AUTHENTICATION of a commitment's commitment method from the
51+
// issuer signature (the method-bound `ZKSIG_M5`/`ZKSIG_M4` PRESENTATION message
52+
// family), plus the relying party's `MethodPolicy` over the result. Always
53+
// compiled: the method axis is not dual-leaf-specific. The default policy accepts a
54+
// method-UNBOUND attestation, so every already-issued credential authenticates
55+
// exactly as before. Opening a value-lane gate is NOT part of this; see
56+
// `value_pin`. Research-grade, NOT externally audited (sq-qhy4).
57+
pub mod method_auth;
58+
// The relying party's value-lane PIN functions: given an authenticated
59+
// `DualLeafV2` method and a typed query FILTER, what the value-lane public
60+
// inputs would have to be. Reporting only — the value-lane verifier gates stay
61+
// CLOSED (see the module docs).
62+
#[cfg(feature = "dual-leaf")]
63+
pub mod value_pin;
5064
// Research-grade disclosure obligations and PRIVATE successful-row witnesses.
5165
pub mod planner;
5266
// sq-rsd3v.3: witnessed-rule-shape N3 derivation — the host mirror of
@@ -102,6 +116,8 @@ pub use dispatch::{
102116
#[cfg(feature = "extended-fragment")]
103117
pub use verifier::{dispatch_fragment, verify_fragment_manifest, FragmentDispatchError};
104118
pub use verifier::EntailmentPolicy;
119+
// Verifier-side commitment-method authentication + the relying party's policy.
120+
pub use method_auth::{AuthenticatedMethod, MethodAuthError, MethodPolicy};
105121
// sq-3e5 + sq-h2v: hidden-index revocation host helpers.
106122
pub use revocation::{merkle_root, merkle_witness, revoke_prover_toml, MerkleWitness};
107123
// sq-6qe / sq-kndw: the ACCEPTED-SET commitment host helpers — the relying

‎crates/sparq-zk-compose/src/manifest.rs‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2284,14 +2284,36 @@ pub struct HiddenIssuerAttestation {
22842284
/// input). Checked byte-equal to the relying party's authoritative root.
22852285
pub key_set_root: FieldHex,
22862286
/// The issuer-signed commitment message `m` the proof binds (the proof's
2287-
/// PUBLIC input). Checked equal to the message the verifier recomputes from the
2288-
/// disclosed commitment + salt + status reference.
2287+
/// PUBLIC input).
2288+
///
2289+
/// For a HIDDEN-ONLY commitment — one with no clear
2290+
/// [`CommitmentAttestation`] — this field is ENFORCED, on EVERY entry
2291+
/// declared over that commitment: each must EQUAL one of the candidate
2292+
/// messages the verifier builds from the disclosed commitment + salt +
2293+
/// status reference (the method-UNBOUND `ZKSIG_C3` message plus the
2294+
/// method-bound `ZKSIG_M5` message of each recognised commitment method),
2295+
/// and all of them must equal the SAME candidate. Which candidate that is
2296+
/// authenticates the commitment's method (`crate::method_auth`), and the
2297+
/// relying party's `MethodPolicy` then applies to that method; a value equal
2298+
/// to no candidate, one that does not decode, or two entries selecting
2299+
/// different candidates are refused
2300+
/// (`crate::verifier::CheckError::HiddenIssuerMessageMismatch`). The bb
2301+
/// stage additionally byte-compares the proof's public input against the
2302+
/// message the verifier recomputes for the authenticated method, so this
2303+
/// field cannot select one method and bind another.
2304+
///
2305+
/// In ADDITIVE mode — a clear [`CommitmentAttestation`] over the same
2306+
/// commitment alongside the hidden proof — the method comes from the clear
2307+
/// signature instead, and the bb stage binds the hidden proof to the message
2308+
/// of THAT method. This declared field must still equal that message: the
2309+
/// hidden-issuer stage checks every entry's declared `m` against the message
2310+
/// its proof is checked against before decoding any proof.
22892311
pub message: FieldHex,
22902312
/// The per-graph RDFC10 bnode salt this commitment was committed under (audit
22912313
/// #9), hex. Carried HERE so the verifier can recompute the issuer-signed
2292-
/// message `m = commitment_message_with_status(C(G), salt, status_ref)` for a
2293-
/// HIDDEN-ONLY commitment — one with NO clear [`CommitmentAttestation`] from
2294-
/// which to read the salt (sq-xxg). The salt is NOT the privacy target (only
2314+
/// message `m` over `(C(G), salt, status_ref)` for a HIDDEN-ONLY commitment —
2315+
/// one with NO clear [`CommitmentAttestation`] from which to read the salt
2316+
/// (sq-xxg). The salt is NOT the privacy target (only
22952317
/// WHICH issuer signed is hidden); disclosing it is consistent with the clear
22962318
/// path, and it still participates in the salt-uniqueness guarantee (audit #9).
22972319
///

0 commit comments

Comments
 (0)