Repository navigation
feat(zk): authenticate the commitment method from the issuer signature - #7082
Conversation
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
… 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
🔎 Codex reviewer —
|
… 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
…ists Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo
|
Head Hidden-only method (high):
Presentation message:
Lows:
Generated by Claude Code |
…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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo
…ssage 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
…essage qualification precisely Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo
|
Merging on the stand-in gates while the automated reviewer is offline. Both gates ran on
ci-fast is green on Generated by Claude Code |
🔎 Codex reviewer —
|
Summary
The verifier used to have to assume which commitment method a committed graph was made under. It now recovers it — and not from the manifest, which the prover writes, but from the issuer signature.
The shape of the argument.
sparq-zk-compose::method_auth::candidate_messagesbuilds one candidate signed message perCommitmentMethod::recognised()method, from values the verifier itself has already established: the commitment, the issuer-attested salt, the issuer-bound status reference, and the attested holder digest when the attestation is holder-bound. Alongside them it builds the method-unbound message (ZKSIG_C3/ZKSIG_C4) it computed before.classifythen asks which of them the presented signature verifies over:AuthenticatedMethod::Bound(m);AuthenticatedMethod::Unbound, which is the explicit absence of an attested method and never a default one;AmbiguousMethod, refused rather than resolved;NoCandidateVerified, which maps to the pre-existingInvalidIssuerSignature.Every candidate is verifier-computed. The prover only selects among them; it contributes no message of its own. That is what makes the answer an authentication rather than a hint.
The relying party decides what to do with the answer.
MethodPolicyrides on the external trust anchor —KeySet::with_method_policy, following thewith_hidden_issuer_depthprecedent, so no public verifier signature changes and no policy is ever read from the manifest.MethodPolicy::current()is the default;require_method_bound()refuses an attestation that binds no method (CheckError::MethodNotAuthenticated); an attested supersededDualLeafV1is refused (CheckError::MethodNotAdmitted) unless the written-downadmit_legacy_dual_leaf_v1()opt-in is present; thevalue-onlyresearch dial is admitted by no policy.Back-compatibility is structural, not a special case. The method-unbound message is itself a candidate, so an already-issued attestation authenticates as
Unbound, the default policy accepts it, andauthenticated_message(.., Unbound)is byte-identical to the message the verifier computed before. A bad signature still makes nothing verify, so it still reportsInvalidIssuerSignatureand the refusal precedence is unchanged. Every existing C3/C4 manifest reaches exactly the verdict it reached before, and the whole existing suite passes unedited.The hidden-only path. A hidden-only commitment has no clear signature to authenticate from. What it does have is the hidden proof's public
m, which the bb gate byte-binds — so the method is recovered one step earlier by matching thatmagainst the same verifier-built candidate set. An unmatchedmrecords nothing, the later recompute falls back to the unbound message, and the existing message byte-compare refuses the entry exactly as it does today.New in
sparq-zk: theZKSIG_M4message (sig::commitment_message_with_holder_and_scheme) — the one shape binding both a holder key and a commitment method — with its two signers;verify::fragment_typed_filters, a datatype-general sibling offragment_filtersthat keeps a FILTER constant as the typedoxrdf::Literalthe query spells (one shared private walker;fragment_filters' behaviour and error strings are unchanged, asserted verbatim by a regression test);commit::CommitmentMethod::recognised(); andCommitmentAttestation::from_registry_entry, which bridges a registry entry's already-method-bound signature into a manifest.Pinned value-lane inputs — and the gates stay closed.
sparq-zk-compose::value_pin(behind the OFF-by-defaultdual-leaffeature) states, for an authenticatedDualLeafV2method and a typed query FILTER, exactly what a value-lane sub-proof's member, lane constant (for the decimal lane, the scale it names, with the query constant rescaled to it exactly and never rounded), op code, bound and verdict would have to be — each equality failing closed with its ownPinError.scan_methodrequires every commitment a scan draws from to have authenticated the same method;check_lane_routedrefuses a lane the whole-graph builder does not route.None of that opens a gate.
prefilter_manifest_structurestill refuses every value-lane sub-proof unconditionally at stage 0 (ValueLaneSubProofNotAccepted), before it reads a sub-proof for anything else; the stage-2 binding-edge operand match still admits only thefilter_intfamily;filter_edge_trueis untouched. A manifest every one of whose pins holds is still refused, andtests/value_lane_pin.rs::a_manifest_whose_pin_passes_is_still_refusedasserts exactly that. Nothing ties a pin to an obligation yet: there is no edge from a value-lane sub-proof to a query FILTER, so nothing establishes that a sub-proof whose pin holds is the one answering that FILTER. Computing a pin is not enforcing one; the gate-opening change is separate (sq-j506).Not externally audited (
sq-qhy4); no soundness or privacy property is claimed. No.nror evidence-pinned file is touched;constraint_pack.py --checkandverify_result_evidence.pyboth pass unchanged.Mutation results (preflight obligation 1)
Each target was mutated one at a time and reverted; the suite was run in between. Every mutation went red, and the test that died is named.
classify:bound_hits > 1→> 2(ambiguity not refused)method_auth::tests::classify_refuses_two_bound_hits_and_reports_nothing_verifyingclassify: returnUnboundbefore a bound hitmethod_auth::tests::classify_prefers_a_bound_hit_and_is_order_independentMethodPolicy:DualLeafV1admitted without the opt-inmethod_auth::tests::an_attested_legacy_method_is_refused_by_policy_not_by_authenticationMethodPolicy::current():accept_unbound: false(the back-compat default)method_auth::tests::default_policy_accepts_unbound_and_refuses_legacy_v1,verifier::tests::two_credentials_with_distinct_status_refs_are_rejected(+3)authenticate_hidden_only_method: alwaysSome(Unbound)verifier::tests::a_hidden_only_commitment_authenticates_the_method_its_message_namesValueLaneSubProofNotAcceptedrefusal removedvalue_lane_pin::a_manifest_whose_pin_passes_is_still_refused,no_method_policy_opens_the_value_lane_gateZKSIG_M4domain tag changedsig::tests::the_m4_message_is_poseidon2_over_the_ascii_tag_and_input_ordervalue_pin::tests::each_pinned_equality_has_its_own_refusal,numeric_promotion_is_not_supportedvalue_pin::tests::each_pinned_equality_has_its_own_refusalvalue_pin::tests::each_pinned_equality_has_its_own_refusaltruevalue_pin::tests::each_pinned_equality_has_its_own_refusalrescale_exact:checked_mul→wrapping_mulvalue_pin::tests::rescale_exact_is_exact_in_both_directions_and_refuses_overflowrescale_exact: inexact scale-down admittedvalue_pin::tests::rescale_exact_is_exact_in_both_directions_and_refuses_overflow,the_decimal_bound_is_rescaled_exactly_to_the_presented_scalecheck_lane_routed: every datatype routedvalue_pin::tests::check_lane_routed_refuses_the_unrouted_seamscan_method: disagreement toleratedvalue_pin::tests::scan_method_requires_all_commitments_to_agreeTwo of these started out green and are the reason this PR carries two tests it would not otherwise have: the
ZKSIG_M4tag (pairwise distinctness cannot pin it, sinceM4is the only six-element preimage in the family, so a re-taggedM4still collides with nothing) and the hidden-only match (its only coverage was in#[ignore]d live tests, which the default lane does not run). Both mutations are now red.One mutation is equivalent, not unkilled:
rescale_exact'sto >= from→to > from. Atto == fromthe else branch computesfactor = 10^0 = 1,is_multiple_of(1)holds andm / 1 == m, so the mutant is behaviourally identical to the original and no test can distinguish it.Base gate (always required)
cargo build --workspacesucceeds.cargo clippy -p sparq-zk -p sparq-zk-compose --all-targets -- -D warningsclean in both feature states (default, and--features sparq-zk-compose/dual-leaf).cargo fmt --alldeliberately not run).cargo test -p sparq-zk -p sparq-zk-composepasses: 696 passed / 0 failed default, 647 passed / 0 failed withdual-leaf.RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-featuresexits 0.markdownlint-cli2@0.18.1reports 0 errors on the changed markdown.Targeted re-evaluation (check the rows that apply to your change)
pubitem):skills/zk-query-proofs/SKILL.mdupdated in this change for every new public item, and the surface's tests re-run.sparq-zk,sparq-zk-compose,zk/): withnargo+bbon PATH —method_auth_binding11/11 including the two live#[ignore]d lanes (an end-to-endverify_manifestwith an M3 attestation, and a hidden-issuer proof over the M3 message),differential_fuzz4/4,audit_forge_map20/20,holder_pop_forge12/12,dual_leaf_v2_lanes40/40,dual_leaf_boolean_lane17/17,dual_leaf_datetime_lane15/15,value_lane_pin6/6. No circuit or gate count moves: no.nrfile is touched, and the existingdual-leaffeature-matrix leg already hastest: true, so the new gated suite runs on CI without a new leg or golden change.Ratchets and conventions
bench/zk-compose/scripts/constraint_pack.py --checkandverify_result_evidence.pyboth pass byte-identical.sq-j506for the gate-opening wiring), not asTODOmarkers.research/zk-field-native-encoding.md§14.5's precondition now states which of its four conditions are enforced verifier-side and which are not,dispatch.rs's precondition comment likewise, andCHANGELOG.mdrecords all three surfaces.Security
sparq-zkandsparq-zk-compose: it removes an assumption from the verifier (the commitment method is authenticated from the issuer signature instead of assumed) and adds a refusal under an opt-in policy, while leaving every existing refusal and its precedence unchanged. The value-lane verifier gates stay closed. Not externally audited (sq-qhy4); no soundness or privacy property is claimed.🤖 Generated with Claude Code
https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo
Generated by Claude Code