Finish the porting of WabiSabi credential system with CLSAG ring signatures and add Python bindings#1
Open
m0wer wants to merge 19 commits into
Open
Finish the porting of WabiSabi credential system with CLSAG ring signatures and add Python bindings#1m0wer wants to merge 19 commits into
m0wer wants to merge 19 commits into
Conversation
The hand-rolled modular arithmetic in crypto/scalar.rs reimplemented add/sub/mul mod n on top of secp256k1::Scalar (which is just a 32-byte tweak with no arithmetic), with a buggy schoolbook multiplier and no documented operator contract. Replace it with a thin newtype around k256::Scalar that delegates to the audited RustCrypto implementation. Also rebase GroupElement on k256::ProjectivePoint to fix InvalidGroupElement returns when multiplying by zero (the zero-amount credential path). While here, fix Credential::value_as_scalar which copied the value as little-endian into a buffer that Scalar::from_bytes parsed as big-endian (silently corrupting credential amounts). Use Scalar::from_u64 directly. Also rewrite Generators::from_text to match the WalletWasabi C# hash-to-curve construction (compute y = sqrt(x^3 + 7) mod p with natural parity) instead of the previous try-both-parities-of-the-hash-as-x heuristic which produced different points than the reference implementation. Test result before: 60 passing, 9-11 failing. Test result after: 67 passing, 5 failing.
…ation CredentialIssuerSecretKey::compute_parameters returned I = ya*Ga, but the issuer-side check Z = CV - (w*Gw + x0*Cx0 + x1*Cx1 + ya*Ca) only reduces to z*I when I = Gv - x0*Gx0 - x1*Gx1 - ya*Ga. With the old I, every credential presentation verification would have failed for a non-trivial credential. Also fix two test fixtures in zero_knowledge::credential_presentation that built value_scalar by copying 8 LE bytes into a 32-byte buffer parsed as BE, making the credential's stored value disagree with the Ma it was issued against. Use Scalar::from_u64 directly. Tests: 69 passing (was 68), 3 failing (was 4).
verify_request_proofs aggregated the prover's per-credential knowledge-of-randomness proofs against verifier-built balance + range statements that did not match in count or shape, so every issuance request failed before reaching the MAC step. Until the full real-amount proof system is wired up, detect a zero-amount request (no presented credentials, delta == 0, no bit commitments) and verify each requested Ma_i against the matching Statement Ma_i = r_i * Gh, which is exactly what create_request_for_zero_amount produces. Real-amount requests are allowed through unchecked for now with a TODO comment. Also bulk-fix lingering Scalar::from_bytes-of-LE-bytes bugs in tests/credential_tests.rs and the csharp_ported tests to use Scalar::from_u64 directly. Tests: lib 72 pass, integration 67 pass (139 total).
Implement the canonical statement and knowledge constructors from
WalletWasabi.Crypto.ZeroKnowledge.ProofSystem so prover and verifier
build byte-identical Sigma protocol matrices:
- issuer_parameters_{statement,knowledge}: 3-equation proof of (w, wp,
x0, x1, ya) underlying both Cw and the issued MAC.
- show_credential_{statement,knowledge}: 4-equation proof binding the
randomized presentation (Ca, Cx0, Cx1, S) to (z, value, randomness, t)
with witness order [z, -t*z, t, value, randomness].
- balance_proof_{statement,knowledge}: single-equation proof that the
balance commitment commits to zero with witness [zSum, rDeltaSum].
- range_proof_{statement,knowledge}: full bit-decomposition statement
(2*width + 1 equations) including per-bit Pedersen commitments and
the b in {0,1} sub-proofs. Witness layout [r, b_i, r_i, rb_i].
- zero_proof_{statement,knowledge}: range proof of width 0, used for
bootstrap credential requests.
- pedersen_commitment helper.
Also tighten Knowledge::new so the witness width is validated against
every equation, not just the first; multi-row matrices produced by
from_matrix already pad with infinity for unused columns.
8 round-trip tests cover prover -> verifier interop for each statement
including a full 51-bit range proof (the typical Bitcoin sat range bound).
Changelog: Add canonical WabiSabi ProofSystem statement and knowledge
constructors required for verifiable real-amount credential issuance
Rewrite WabiSabiClient and CredentialIssuer to share a single
`UnifiedRegistration/{N}/{isNull}` transcript across presentations,
range/zero proofs, balance proof, and MAC issuance proofs (matches
WalletWasabi C# canonical algorithm).
Fix root cause of failing real-amount round-trip: range_proof_knowledge
now returns the original Ma alongside the bit commitments. Previously
the client extracted Ma from `statement.public_points()[0]`, which for
width > 0 holds `Ma - sum(2^i * B_i)` rather than Ma itself. The
malformed Ma was then sent in the IssuanceRequest, causing the issuer
to rebuild a different range-proof statement and a different balance
commitment, so the Fiat-Shamir challenges diverged and verification
failed. Width-0 (zero issuance) was unaffected because the subtracted
sum is empty.
Add IssuanceValidationData and CredentialsResponseValidation to carry
the post-issuance state needed to finalize MACs into Credentials.
Add WabiSabiError::InvalidAmount.
Add tests/round_trip_tests.rs::zero_then_real_round_trip exercising
both the null and real-amount issuance paths back-to-back.
Changelog: WabiSabi credential issuance now supports real (non-zero)
amounts with full proof verification round-trip.
Adds Rust ports of the splitting and invalid-request scenarios from WalletWasabi.Tests/UnitTests/WabiSabi/CredentialTests.cs: - credential_issuance_full_lifecycle: zero -> real -> reissuance/split - invalid_request_wrong_number_of_requested - invalid_request_wrong_number_of_presented - invalid_request_bad_bit_commitments - invalid_request_swapped_zero_proofs - double_spend_serial_number_rejected - range_proof_width_matches_max_amount Required wiring on the issuer: - explicit InvalidNumberOfRequestedCredentials/PresentedCredentials errors - per-request bit-commitment width validation - with_max_amount builder + range_proof_width/max_amount accessors And the client gained a matching with_max_amount/range_proof_width pair so both sides agree on the bit width derived from the configured maximum amount, mirroring CredentialIssuer.cs.
Adds Rust ports of WalletWasabi.Tests/UnitTests/Crypto/{MacTests,
CredentialIssuerKeyTests}.cs:
- mac_rejects_zero_t / mac_rejects_infinity_attribute
- mac_can_produce_and_verify, mac_can_detect_invalid
- mac_equality_truth_table (full 8-case combinatorial sweep)
- issuer_secret_key_rejects_zero_in_each_field
- issuer_secret_key_accepts_all_nonzero
- issuer_parameters_rejects_infinity
Required wiring on the crypto layer:
- WabiSabiError::ZeroScalar { name } and ::PointAtInfinity { name } that
carry the offending parameter name, matching the C# ArgumentException
messages.
- CredentialIssuerSecretKey::try_from_scalars: validating constructor that
rejects any zero scalar component. CredentialIssuerSecretKey::new now
loops on the (negligible) chance of a zero from the random source so its
output is always valid.
- CredentialIssuerParameters::try_new: validating constructor that rejects
the point at infinity for either group element.
SchnorrBlinding tests are intentionally not ported: the legacy ECDSA
blind-signature module is not part of the WabiSabi credential system and
Downstream consumers use plain BIP340 Schnorr instead.
Direct port of WalletWasabi.Tests/UnitTests/Crypto/ProofSystemTests.cs covering the high-level statement constructors: - can_prove_and_verify_mac: issuer-parameters knowledge round-trip plus tamper detection via reversed responses or nonces. - can_prove_and_verify_mac_show: show-credential proof against a randomized presentation, asserting Z = z*I. - can_prove_and_verify_presented_balance: balance proof for a presented credential B = Ca - a*Gg, with shifted/raw rejection cases. - can_prove_and_verify_requested_balance: balance proof for a requested attribute B = a*Gg - Ma using the (0, -r) witness. - can_prove_and_verify_balance_*: 12 theory cases mirroring the C# parameterised test, covering zero, equal, asymmetric and 32-bit boundary deltas. - can_prove_and_verify_zero_proofs: paired zero-attribute proofs through the multi-knowledge transcript. - zero_proof_rejects_nonzero_attribute: assert_soundness rejects an attribute that does not commit to zero. All 179 tests pass.
Add the deterministic-RNG variant of the synthetic-nonces divergence
test (WalletWasabi.Tests/UnitTests/Crypto/TranscriptTests.cs).
The existing test_synthetic_nonces_uniqueness covers the SecureRandom
case where every provider receives independent entropy, but cannot
distinguish 'transcript state caused divergence' from 'fresh randomness
caused divergence'. This new test feeds every provider a StepRng(0, 0)
zero-byte stream so the only entropy source is the transcript-bound
state. We assert:
- different commitment OR different witness still produce distinct
nonces (transcript state is the sole differentiator), and
- identical commitment + identical witness + zero RNG produce
identical nonces (sanity check that the only divergence vector
is the transcript state itself).
This guards the safety claim that synthetic nonces stay unique even
when the host RNG is broken.
The accompanying StrobeTestVectors.json from the WalletWasabi suite is
not vendored in the upstream tree we cloned; Strobe-128 conformance is
already validated by strobe-rs's own KAT suite, so we do not duplicate
StrobeTests.cs.
9 transcript tests pass; 180 total.
Two small library additions to support test ports of the WabiSabi credential-requesting wire surface: * Derive PartialEq + Eq on CredentialPresentation, ZeroCredentialsRequest, and RealCredentialsRequest. Their fields are already structurally comparable (GroupElement, IssuanceRequest, Proof) so the derive is free, and downstream tests/binding code can now check protocol-level wire equality without manual field-by-field comparison. * Re-expose generators::from_text as Generators::from_text. The free function was already public but had no associated-function surface, while WalletWasabi tests and downstream code routinely call it as Generators.FromText(label). Forwarding it from the Generators impl matches the C# spelling without changing behaviour.
Direct port of the WalletWasabi credential-request equality tests:
* WalletWasabi.Tests/UnitTests/WabiSabi/Crypto/CredentialRequesting/
ZeroCredentialsRequestTests.cs::EqualityTest
* WalletWasabi.Tests/UnitTests/WabiSabi/Crypto/CredentialRequesting/
RealCredentialsRequestTests.cs::EqualityTest
Both upstream tests guard the wire-DTO equality contract: two requests
built from identical inputs must compare equal, and a one-byte change
in the Ma attribute commitment must compare not-equal. We mirror the
exact factory shape (Generators::from_text labels, Proof responses,
IssuanceRequest with two bit commitments, RealCredentialsRequest with
delta=123_456 and a single CredentialPresentation) so the test reads
1:1 against the C# source.
Also adds request_accessors_round_trip as a minimal sanity check that
delta() / presented() / requested() / proofs() expose the constructed
state, since the equality tests do not exercise the trait surface
directly.
Total: 183 tests pass.
Removes two placeholder methods that no caller imported and that encoded incorrect or stub behaviour: * Credential::create_presentation_witness produced a four-element witness (value, randomness, z, t) that does not match any statement built by the canonical UnifiedRegistration transcript. The actual show-credential proof uses statements::show_credential_knowledge, whose witness is five elements [z, -t*z, t, value, randomness]. The placeholder was never wired into the live proof flow and would have silently produced an unverifiable proof if it had been. * CredentialPresentation::create_knowledge_statement returned WabiSabiError::Unspecified unconditionally. The canonical show-credential statement is built from statements::show_credential_statement against the freshly-computed Z = z * I, so this stub had no path to becoming correct without duplicating that constructor. Dropping both keeps the public API honest about what is implemented and clears a category of footguns when downstream PyO3 bindings start enumerating the credential surface. Also fixes the unused-mut warnings in nonce_provider.rs:114-115. The two providers were created only to ensure the call type-checks; bind them to underscore-prefixed names to make the intent explicit and quiet the rustc warning.
Adds pyo3 0.22 as an optional dependency gated on the new `python` feature, plus bincode 1.3 for the upcoming bytes-in/bytes-out wire DTO codec at the Python boundary. Choices: * abi3-py311: target the stable Python ABI starting at 3.11. A single wheel works for 3.11/3.12/3.13/3.14, which is convenient for downstream (Python 3.11) and lets the Docker image upgrade to 3.14 without a rebuild. * extension-module: required when pyo3 builds a cdylib loaded by the CPython interpreter; without it, link symbols would conflict on Linux. * dep:pyo3 syntax: keeps the feature name (`python`) distinct from the crate name so cargo does not auto-implicit-feature it, which would leak pyo3 into default builds. Default cargo build/test stays slim (no pyo3 download); `cargo build --features python` enables the binding surface that follows in the next commit.
…odecs GroupElement::serialize emits a 33-tuple of bytes (compact SEC1 form), but the matching Deserialize impl decoded a Vec<u8>. In length-prefixed binary codecs (bincode default, postcard, MessagePack) Vec<u8> reads a length tag first, so a fresh-tuple-then-Vec round-trip overshoots the buffer end and surfaces as 'unexpected end of file'. JSON happens to hide the bug because seq and tuple share representation there. Replace the deserializer with a length-checked tuple visitor that * iterates exactly 33 elements via SeqAccess (the canonical pyo3 + bincode path), * accepts visit_bytes too (CBOR and similar compact codecs encode fixed-length byte arrays as bytes blobs). The fix is what the existing tests had been masking via the in-process JSON-friendly serializers; the upcoming PyO3 layer round-trips through bincode and exposed it. No public API change, no on-wire change in the already-correct serialize direction. Changelog: Fix GroupElement deserialization under length-prefixed serde codecs (bincode, postcard, MessagePack)
Adds a Python extension module that exposes the full client/issuer-side WabiSabi flow: issuer setup, zero-amount and real-amount credential requests, response validation, and credential persistence. The module is gated on the optional 'python' cargo feature added in 4e36c4b, so plain 'cargo test' and downstream Rust consumers stay PyO3-free. Surface ------- * generate_issuer_secret_key() / derive_issuer_parameters(sk) -> bytes * CredentialIssuer(sk_bytes, initial_balance) with parameters(), balance(), reset(), handle_request(req_bytes, is_real) * WabiSabiClient(params_bytes) with configure(max_amount, width), create_request_for_zero_amount(), create_request(amounts, presented), handle_response(resp_bytes, validation) * Credential.to_bytes() / from_bytes() for wallet-side persistence * ValidationHandle: opaque per-request state threaded between create_request and handle_response Design choices -------------- * Bincode at the FFI boundary for every wire DTO. Avoids a parallel Python class hierarchy and keeps Rust as the single source of truth for the wire format. Python wrappers can later swap the codec without touching the binding signatures. * Opaque ValidationHandle for CredentialsResponseValidation. Its inner Transcript carries a Strobe state that has no stable serialization. Snapshotting the Strobe state would be fragile across upstream releases; rebuilding it per call would be slow and racy. Holding it as a #[pyclass] with take()-on-consume keeps the round-trip type safe and the hot path zero-copy. * Fresh SecureRandom per call rather than threading one from Python. Matches the reference WabiSabi behaviour (system entropy on every proof) and keeps the Python API thread-safe without forcing a Send + Sync guarantee on the Rust RNG. * abi3-py311 wheel: a single artifact targets CPython 3.11+ so the same wheel works across 3.11 through 3.14 without a rebuild. extension-module is required for the cdylib loaded by CPython. * CredentialIssuer::reset is widened from #[cfg(test)] to also be available under the 'python' feature. Cross-round reuse of an issuer instance is a legitimate runtime need; rebuilding the secret-key + parameter setup per round would burn entropy and break long-lived issuer instances. Tests ----- python/tests/test_round_trip.py exercises: * zero-amount mint + verify * present zero credentials and reissue against a real-value vector * double-consume of a ValidationHandle raises RuntimeError * Credential bytes survive serialize/deserialize * derive_issuer_parameters(sk) == issuer.parameters() Build / run from the crate root: python -m venv .venv && . .venv/bin/activate pip install maturin pytest maturin develop --features python pytest python/tests/ All 5 Python e2e tests + the full Rust test suite pass.
…e_proof_width WabiSabiClient already exposed configure() so callers could pin the range-proof width to their protocol's value. CredentialIssuer did not, which meant the issuer always ran with the in-crate defaults (MAX_AMOUNT = 2**27, RANGE_PROOF_WIDTH = 27). When a downstream protocol used anything wider (for example a 2**51 max amount), every real-amount request failed with 'Invalid bit commitment' because the issuer was checking against a 27-bit commitment matrix while the client was building a 51-bit proof. The fix mirrors the client API: a single configure(max_amount, range_proof_width) call rebuilds the issuer through the existing with_max_amount + with_range_proof_width builders. Implementation notes: * Inner issuer wrapped in Option<RsIssuer> so the builder can move through self-by-value without requiring CredentialIssuer to impl Default. The slot is only ever empty for the duration of one synchronous configure() call; PyO3's GIL guarantees no other pyclass method observes the empty state. * Accessor helper issuer() centralises the unwrap so individual methods stay readable. Panics carry the contract violated (slot emptied during configure) for fast post-mortem. No on-wire change. New API surface is additive.
Adds a single-key LSAG construction with a per-run rotating key image
suitable for anonymous attestations. The signer holds the discrete log
behind one of N x-only ring members (BIP340 even-Y lift); verifiers
recover the LSAG core image by subtracting H_s("rotate" || run_id) * G,
which keeps within-run linkability (Sybil detection by key-image
equality) while making cross-run signatures unlinkable.
Hash-to-point uses k256's secp256k1_XMD:SHA-256_SSWU_RO_ via
GroupDigest, with all DSTs prefixed jmng/clsag/v1/. Wire format is
33 + 32 + 32*N bytes (compressed key image, c0, then one s_i per ring
member). 8 unit tests cover round-trip, message/run-id/signer
tampering, key-image rotation, and wire serialization.
Changelog: Add CLSAG-style linkable ring signatures (sign/verify/key
image) for anonymous attestations.
Wires the new ring-signature primitives through the PyO3 boundary using
the same byte-oriented contract as the rest of nwabisabi:
clsag_sign(secret_key, ring_xonly, signer_idx, run_id, message)
-> bytes (33 + 32 + 32*N signature blob)
clsag_verify(signature, ring_xonly, run_id, message)
-> (bool, key_image_bytes)
The 33-byte key image is returned even on verification failure so
callers can dedupe at the gossip layer without re-parsing the
blob.
clsag_key_image(secret_key, run_id) -> bytes
Pre-flight Sybil check without paying the cost of building a full
ring signature.
Two crate-private hash-to-point / run-rotation helpers are re-exported
under #[cfg(feature = "python")] so the binding can compose them
without leaking DST constants from the public surface. Bumps both the
Cargo and pyproject versions to 0.2.0 (new public API surface).
Five PyO3-level tests verify FFI shape, error mapping, key-image
rotation, and the (ok, key_image) tuple contract; the cryptographic
edge cases stay covered by the Rust unit tests in src/crypto/clsag.rs.
Changelog: Add Python bindings for CLSAG sign/verify/key-image.
Adds RegistrationShow proof that demonstrates knowledge of a valid MAC on a credential while revealing the credential's amount and exposing its compressed serial point. The transcript is bound to a caller- supplied label so verifiers can pin the show to a specific output context (epoch / output-type / address / amount). Also exposes Credential::serial() for callers that need to hash the serial into protocol-level state, and a CredentialIssuer helper verify_registration_show that recomputes z_public from sk and rejects shows that do not match the issuer's parameters. Dedup of seen serials is left to the caller (coordinator). PyO3 bindings wired through: - Credential.serial() -> bytes - WabiSabiClient.present_for_registration(cred, label) -> bytes - CredentialIssuer.verify_registration(blob, amount, label) -> bytes Bumps crate to 0.3.0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Full implementation of the WabiSabi anonymous credential protocol (ported from WalletWasabi C#) with CLSAG-style linkable ring signatures, a complete PyO3 Python extension module, and a registration show proof. Brings the crate from a stub to a functionally complete, test-verified library at version
0.3.0.Changes
Cryptographic correctness
k256::Scalar/k256::ProjectivePoint; fixes a buggy schoolbook multiplier andInvalidGroupElementon zero-scalar multiplyCredentialIssuerSecretKey::compute_parameters— issuer parameterIwasya*Gainstead ofGv - x0*Gx0 - x1*Gx1 - ya*Ga, breaking all credential presentation verificationCredential::value_as_scalarLE/BE mismatch (now usesScalar::from_u64)Generators::from_textto match WalletWasabi'ssqrt(x³+7) mod phash-to-curve constructionGroupElementserde: deserializer was readingVec<u8>(length-prefixed) instead of a 33-tuple, breaking all bincode/postcard round-tripsZero-knowledge proof system
ProofSystemstatement constructors:issuer_parameters,show_credential,balance_proof,range_proof(full bit-decomposition, up to 51-bit),zero_proof,pedersen_commitmentKnowledge::newto validate witness width against every equation rowWabiSabiClientandCredentialIssueronto a singleUnifiedRegistration/{N}/{isNull}transcript; fixrange_proof_knowledgeto return the originalMa(notMa - sum(2^i * B_i))RegistrationShowproof: reveals credential amount and exposes compressed serial point, transcript-bound to a caller-supplied labelProtocol features
CredentialIssuer::with_max_amount/with_range_proof_widthbuilders + matching client-side pair so both sides agree on bit widthCredential::serial()accessor for coordinator-level dedupGenerators::from_textassociated function (mirrors C#Generators.FromText)PartialEq/EqonCredentialPresentation,ZeroCredentialsRequest,RealCredentialsRequestcreate_presentation_witness,create_knowledge_statement) that encoded incorrect witness layoutsCLSAG ring signatures (
src/crypto/clsag.rs)secp256k1_XMD:SHA-256_SSWU_RO_with DST prefixjmng/clsag/v1/33 + 32 + 32*Nbytes (compressed key image,c0, ones_iper ring member)Python bindings (PyO3,
--features python)generate_issuer_secret_key(),derive_issuer_parameters(sk)CredentialIssuer:configure(max_amount, width),handle_request,resetWabiSabiClient:configure,create_request_for_zero_amount,create_request,handle_responseCredential:to_bytes/from_bytes,serial()WabiSabiClient.present_for_registration(cred, label),CredentialIssuer.verify_registration(blob, amount, label)clsag_sign,clsag_verify(returns(bool, key_image_bytes)),clsag_key_imageabi3-py311wheel;extension-modulecdylib;bincodeat all FFI boundaries; opaqueValidationHandleto hold non-serialisable Strobe transcript stateTest coverage
Version bumps
FYI: the last three commits are new, not a strict port. But needed them for some project.
PS: Thanks for creating the library, and for starting the Rust port!