Repository navigation
Add DLEQ proof implementing BIP 374 - #1802
macgyver13 wants to merge 8 commits into
Conversation
|
Have you talked with @stratospher before opening this PR? If you have contacted stratospher, please ignore this comment. It's just to avoid putting the original author in an unfair or difficult position if still working on the PR. |
Yes, stratospher and I had discussed who should submit this new PR. I drew the short straw :) |
3c538a6 to
30d6f5e
Compare
stratospher
left a comment
There was a problem hiding this comment.
sorry for the confusion and thank you @macgyver13 for the PR!
did an initial pass and it looks good! mostly left style nits.
1 API design question I had is whether it would be better for the proof generation API to also accept C as an argument - that is GenerateProof(a, B, r, G, m, C) kind of API instead of GenerateProof(a, B, r, G, m).
When computing silent payment output points, we anyways compute shared secret/C for each output - so there's a possibility to avoid recomputation again if we can pass C. But current approach might be safer since callers could accidentally provide incorrect C and errors could happen.
Did you check other use cases of DLEQ for whether they might prefer passing C or computing C internally in the proof generation function?
|
I have a question regarding the choice of parameters passed to this API, and I may be missing some design context here — so I’d appreciate clarification. Other constructions with two generators on the same curve (e.g., Pedersen commitments, bulletproofs) typically derive the second generator using a hash‑to‑curve procedure, ensuring that no participant knows the discrete‑log ratio between |
30d6f5e to
45911b2
Compare
You probably mean soundness instead of correctness? If I'm not mistaken, this is simply not true. See for instance Nigel Smart's Cryptography: An Introduction (3rd Edition), Section 25.3.1, page 377, PDF page 389. This rather accessible write-up shows that the underlying Sigma protocol has correctness, 2-special soundness, and zero-knowledge, all with probability 1 and without any computational assumption. Technically, one doesn't even need the assumption that the discrete logarithm problem is hard in the group. Note in particular that the proof of special soundness extracts the witness and not the discrete logarithm between g and h (or G and B in our notation); the latter doesn't even show up in the proof.
Can you elaborate and also explain why you believe that this requires the assumption that the prover doesn't know the discrete logarithm between G and B? |
|
Thank you for the review @stratospher and appreciate the curious questions about the API!
If we accept
I am not well versed in other DLEQ use cases outside Silent Payments, so I surveyed related applications of Discrete Log Equivalence starting with Andrew Toth's BIP-374 review on the OpTech Podcast. Below you will find a list of reviewed use cases and my assessment of whether to require output points like DLEQ implementations reviewed:
Related protocols reviewed (not DLEQ-based):
I don't see a compelling reason to change the API based on these findings, but I'm open to different perspectives. |
|
Thanks for your response and for the explanation.
Yes — thanks for the correction.
Agreed. I’ve encountered DLEQ mostly in other contexts, and in particular in scenarios involving DLEQ across two different curves. In those settings, Pedersen commitments are typically used, which require the independence of the two generators.
My reasoning was that the second verification equation (the one checking R₂ in this notation) is effectively just the first verification equation multiplied by |
45911b2 to
e820661
Compare
|
Rebased on latest master and adapted the module to two API changes it introduced, no behavior changes.
Folded into the affected commits:
|
e820661 to
28578fc
Compare
|
Thank you for the review @theStack! Your suggestions all made sense and I've applied them. While I was reviewing your feedback I also made some additional changes based on the findings of several agentic review passes:
Also rebased on master, and folded the earlier "Apply review feedback" commit into the commits it touched; the series is five commits now. If you spot any other improvements, let me know. |
28578fc to
d35b773
Compare
|
My last local rebase missed #1915, which moved these helpers to group.h and renamed them:
|
|
This was discussed in yesterday's IRC meeting:
|
There was a problem hiding this comment.
Thanks for following up! Left some more small findings below, some of them quite pedantic (feel free to ignore if they seem too nit-picky). Seems like a rebase on master is also necessary due to #1908.
A few other notes:
- there is consensus that we prefer directly including the test vector source data from BIPs (the two .csv files in this case) in this repository, see e.g. #1698 (comment) and #1786
- the test vector generation script
test_vectors_dleq_generate.pyis missing the executable flag - an entry in the features list of README.md is missing
- adding an example
examples/dleq.cwould be nice, but could still be done in a follow-up (thinking of how users would multiply with arbitrary points feels a bit "hazmat"-y though, they would need to abuse the pubkey tweaking function...)
As mentioned on IRC earlier today, I don't have a really strong opinion on whether it makes sense to fully expose the public API functions already now or not (curious what others think), but it certainly can't hurt to prepare for it.
|
Thanks @theStack for the detailed review and @real-or-random for relaying the meeting On scope: I've been reflecting on this today, and I've come round to thinking the safer
The shape follows from BIP375 having two forms of share/proof, a global one over every eligible /* Signer holding EVERY eligible input key, on one device:
* derive the outputs and the global share/proof together, sharing the single ECDH
* per unique scan key that both steps need. This is #1651's existing function,
* shown with master's parameter naming. Would fill PSBT_GLOBAL_SP_ECDH_SHARE / _DLEQ,
* from dleq_data, subject to the question below the block. */
int secp256k1_silentpayments_sender_create_outputs_with_proof(
const secp256k1_context *ctx,
secp256k1_xonly_pubkey **generated_outputs,
secp256k1_silentpayments_dleq_data **dleq_data,
size_t *n_dleq_size,
const secp256k1_silentpayments_recipient **recipients,
size_t n_recipients,
const unsigned char *outpoint_smallest36,
const secp256k1_keypair * const *keypairs,
size_t n_keypairs,
const unsigned char * const *seckeys,
size_t n_seckeys
);
/* Signer creates the ECDH share and DLEQ proof for ONE scan key. Derives no outputs,
* so a caller holding only some of the eligible input keys can still contribute its
* share to a transaction it cannot complete alone.
*
* The share is computed over the sum of whatever keys are passed, and that choice
* decides which PSBT field the result belongs in:
* all eligible input keys -> global share -> PSBT_GLOBAL_SP_ECDH_SHARE / _DLEQ
* one input's key -> that input's -> PSBT_IN_SP_ECDH_SHARE / _DLEQ
*
* Which mode to use is the caller's decision: BIP375 allows a signer holding every key
* to still choose per-input ("or does not want to create a global ECDH share").
*
* Call once per scan key in global mode; once per (input, scan key) pair in per-input
* mode. Keys passed via keypairs are treated as taproot inputs and even-Y negated
* before summing as in _sender_create_outputs. */
int secp256k1_silentpayments_sender_create_share_and_proof(
const secp256k1_context *ctx,
unsigned char *share33,
unsigned char *proof64,
const secp256k1_pubkey *recipient_scan_pubkey,
const unsigned char *aux_rand32,
const secp256k1_keypair * const *keypairs,
size_t n_keypairs,
const unsigned char * const *seckeys,
size_t n_seckeys
);
/* Signer verifying shares it did NOT create, holding no key for those inputs:
* BIP375 assigns this to the party that verifies proofs "for all inputs it does
* not have the private keys for".
* One pubkey for the per-input case, all of them for the global case. */
int secp256k1_silentpayments_verify_share_proof(
const secp256k1_context *ctx,
const unsigned char *share33,
const unsigned char *proof64,
const secp256k1_pubkey *recipient_scan_pubkey,
const secp256k1_xonly_pubkey * const *xonly_pubkeys,
size_t n_xonly_pubkeys,
const secp256k1_pubkey * const *pubkeys,
size_t n_pubkeys
);
/* Transaction Extractor, holding NO secret keys at all:
* compute output scripts from shares supplied by others. This is the case
* _sender_create_outputs_with_proof cannot cover, since it requires the seckeys
* in order to derive the outputs itself.
* Takes one already-summed share per scan key, keyed by share_scan_pubkeys, so a
* caller holding per-input shares combines them first.
* Input pubkeys for every eligible input are needed, since input_hash commits
* to their sum. */
int secp256k1_silentpayments_sender_create_outputs_from_shares(
const secp256k1_context *ctx,
secp256k1_xonly_pubkey **generated_outputs,
const secp256k1_silentpayments_recipient **recipients,
size_t n_recipients,
const unsigned char *outpoint_smallest36,
const secp256k1_pubkey * const *share_scan_pubkeys,
const unsigned char * const *shares33,
size_t n_shares,
const secp256k1_xonly_pubkey * const *xonly_pubkeys,
size_t n_xonly_pubkeys,
const secp256k1_pubkey * const *pubkeys,
size_t n_pubkeys
);A sender holding every key should keep using the first, unchanged in shape, since One question came out of reading #1651 to work out how the above would fit alongside it. If I'm reading BIP375 correctly, the value it wants in For what it's worth, @theStack once you've had a chance to look at these signatures we can work out who's best I am still in the exploratory phases of new work that combines MuSig2 and Silent Payments |
|
@macgyver13: Thanks for your thoughts and the API proposal! Since this PR is focusing on the generic DLEQ module and could be useful for non-SP use-cases as well, I've opened a SP-specific issue for further discussion (with emphasis on what I think main use-case for DLEQs in SP nowadays, i.e. verification of created outputs), where I will reply in more detail to your post within the next days: #1925 |
brunoerg
left a comment
There was a problem hiding this comment.
Mutation testing report for this PR is available at: https://secp256k1.space/pull/1802
d35b773 to
a954856
Compare
|
Thanks again @theStack. All of your review points were applied to the original commit series. I did not provide a dleq.c example in this update - still on my todo list. Additionally, included Rebased on master and adapted to Verified generator produced same output after |
|
Thank you for your thoughts on the I surveyed several implementations testing the DLEQ API (Coldcard, Jade/libwally, spdk, silent-pay). Each one computes This is the shape I have in mind: Interested in your thoughts. |
|
Concept ACK. A couple of observations on verifier efficiency and the prover API shape:
secp256k1_ecmult(&tmpj, &Cj, &e_neg, NULL);
secp256k1_gej_set_ge(&Bj, B);
secp256k1_ecmult(&R2j, &Bj, s, NULL);
secp256k1_gej_add_var(&R2j, &R2j, &tmpj, NULL);Since secp256k1_fe aux[2 * ECMULT_TABLE_SIZE(WINDOW_A)];
secp256k1_ge pre_a[2 * ECMULT_TABLE_SIZE(WINDOW_A)];
struct secp256k1_strauss_point_state ps[2];
struct secp256k1_strauss_state state;
secp256k1_gej pts[2];
secp256k1_scalar scs[2];
pts[0] = Bj;
pts[1] = Cj;
scs[0] = *s;
scs[1] = e_neg;
state.aux = aux;
state.pre_a = pre_a;
state.ps = ps;
secp256k1_ecmult_strauss_wnaf(&state, &R2j, 2, pts, scs, NULL);In a benchmark over 50,000 runs, this drops
|
Returning One detail question (inspired by @Yudis-bit's comment 2. immediately above) might be whether this out-parameter should be optional, but I'd lean towards no: even if the user have calculated the C themselves, it still seems useful to compare the re-calculated C against the expected value, so there should always be a use-case for getting it. Only a naming nit (entering bikeshedding territory): maybe the parameter name could be adapted to indicate it's an out-parameter, in order to avoid users passing in an already-calculated C and overwriting it (maybe something like
Thanks for investigating this potential speed-up, I think this would make a good follow-up PR candidate. |
|
Sounds good. Making Happy to defer the 2-point Strauss verifier loop to a follow-up to keep this PR focused on the BIP 374 spec. |
e58ce3f to
4a1d3c1
Compare
|
Thank you for the review @theStack and @Yudis-bit! This push addresses the last round of feedback. Review nits:
BIP-374 vectors moved to the public API tests:
Also rebased on master. @Yudis-bit, thank you for sharing the speed up measurement. I agree with keeping that for a follow-up so this PR stays focused on BIP-374. |
theStack
left a comment
There was a problem hiding this comment.
Thanks for following up!
ACK modulo two small improvement suggestions
4a1d3c1 to
e15009f
Compare
|
Latest push applies @theStack's review comments and adds his benchmark commit:
Also rebased on master. |
theStack
left a comment
There was a problem hiding this comment.
ACK e15009f
organizational nit: Not a blocker at all, but it might be slightly confusing at the first review sight that the API header declarations are added before the corresponding implementation, which comes two commits later. Could still add an (almost empty) header in the "add module structure" commit, and only add the API declarations in the "add public API wrappers" commit.
|
Cross-checked e15009f against btclib-ecc's independent pure-Python BIP374 implementation: no disagreement. Over 5000 random inputs, called through ctypes on the shared library built at this head, the proofs were byte-identical and verified both ways, and both sides rejected all 52,552 cases with an altered proof, point or message. The script is available. |
ViniciusCestarii
left a comment
There was a problem hiding this comment.
ACK e15009f
Nice work! The code and tests look good.
I also wrote some additional BIP-374 test vectors (bitcoin/bips#2335). This implementation passes all of them.
IsaqueFranklin
left a comment
There was a problem hiding this comment.
ACK e15009f
I reviewed and understood the code, ran the tests and everything looks good to me! I just raised that question about checking infinity points on inputs, but that's totally non-blocking for me.
| /* Fail verification if R1j or R2j are infinity */ | ||
| if (secp256k1_gej_is_infinity(&R1j) || secp256k1_gej_is_infinity(&R2j)) { | ||
| return 0; |
There was a problem hiding this comment.
Should we also be checking A, B, C for infinity point on this function (not exactly in these lines)? The _prove_internal does it, and if I'm not mistaken those checks seem redundant there, at least when called through the wrapper dleq_prove. The verify wrapper loads (A, B, C) with secp256k1_pubkey_load (a valid secp256k1_pubkey cannot represent infinity) and the prove wrapper computes A and C using a valid nonzero scalar a and a valid point B (that comes from a secp256k1_pubkey). So in both cases the checks to reject infinity on input points seem redundant. Should we try and be consistent in both cases?
There was a problem hiding this comment.
Thanks @IsaqueFranklin, good catch!
This is an artifact from moving the tests to the public API. The vector tests originally called _prove_internal directly with B at infinity. Your argument for consistency is spot on.
I looked at the rest of the library, which uses VERIFY_CHECK for invariants established at the API boundary. I replaced the runtime check in _prove_internal with VERIFY_CHECK, following the approach suggested in bitcoin-core/secp256k1#1765 (comment).
I kept afb2c9f as a separate commit so reviewers can weigh in before I squash it.
| /* Infinity checks */ | ||
| { | ||
| secp256k1_ge p_inf; | ||
| secp256k1_ge_set_infinity(&p_inf); | ||
| CHECK(secp256k1_dleq_prove_internal(CTX, &e, &s, &a, &p_inf, &B, &C, aux_rand, msg) == 0); | ||
| CHECK(secp256k1_dleq_prove_internal(CTX, &e, &s, &a, &A, &p_inf, &C, aux_rand, msg) == 0); | ||
| CHECK(secp256k1_dleq_prove_internal(CTX, &e, &s, &a, &A, &B, &p_inf, aux_rand, msg) == 0); | ||
| } |
There was a problem hiding this comment.
Related question here. Should we also be bypassing the wrapper for the _verify_internal function and be testing it for infinity on the input points? In that case, _verify_internal would need checks on the points to reject infinity inputs.
There was a problem hiding this comment.
These tests were removed in afb2c9f. They covered the runtime rejection branch, which was needed when the vector tests called _prove_internal directly.
|
In --- a/src/modules/dleq/main_impl.h
+++ b/src/modules/dleq/main_impl.h
@@ -160,6 +160,7 @@ static int secp256k1_dleq_prove_internal(const secp256k1_context *ctx, secp256k
/* The nonce is still secret here, but it being zero is less likely than 1:2^255. */
secp256k1_declassify(ctx, &ret, sizeof(ret));
if (!ret) {
+ secp256k1_scalar_clear(&k);
return 0;
}
@@ -262,6 +263,7 @@ int secp256k1_dleq_prove(
is_sec_valid = secp256k1_scalar_set_b32_seckey(&a, seckey32);
secp256k1_declassify(ctx, &is_sec_valid, sizeof(is_sec_valid));
if (!is_sec_valid) {
+ secp256k1_scalar_clear(&a);
return 0;
} |
e15009f to
afb2c9f
Compare
|
Thanks for looking at this @Yudis-bit. I don’t think either clear is needed:
|
|
Fair point, missed the prior discussion and that |
There was a problem hiding this comment.
re-ACK afb2c9f
I agree with the change in the last commit, doing the non-infinity assurance checks with VERIFY_CHECK in internal functions seems reasonable. The new BIP-374 test vectors in bitcoin/bips#2335 have been merged upstream, so if you squash and force-push, you could also update these to v0.3.1. Doing that in a follow-up seems to be fine as well though.
Implements BIP-374 Discrete Log Equality (DLEQ) proofs as a new module. - Build system integration (CMake, autotools) - Configure dleq module as optional, but enabled in dev mode - Public API header declarations - Internal cryptographic implementation (prove_internal, verify_internal) - Tagged SHA256 functions per BIP-374 specification - Nonce generation following BIP-374 Proof generation requires that A, B and C are not infinity, enforced with VERIFY_CHECK, and verification rejects proofs where R1 or R2 is infinity. The secret-derived buffers (the masked key, the nonce and the serialized secret scalar a) are cleared before returning. Co-authored-by: stratospher <44024636+stratospher@users.noreply.github.com>
Adds tests for the DLEQ internal functions. - Tagged hash midstates checked against secp256k1_sha256_initialize_tagged - Proof round trips, with tampered scalars and points rejected - Nonce generation: sensitivity to each input, and ZERO_MASK matching 32 zero bytes of aux_rand - prove_internal rejects a zero nonce - verify_internal rejects proofs where R1 or R2 is infinity, with a separate case for each so both checks are exercised Public API tests and the BIP-374 test vectors are added in a subsequent commit. Co-authored-by: stratospher <44024636+stratospher@users.noreply.github.com>
Adds public API functions that wrap the internal DLEQ implementation: - secp256k1_dleq_prove(): Generate DLEQ proof and ECDH share point C from secret key and base point B. - secp256k1_dleq_verify(): Verify DLEQ proof given A, B, C public keys. Enable constant-time tests in src/ctime_tests.c. A and C are the public statement the proof is about, so both are declassified; is_sec_valid is declassified separately because secret key validity is allowed to branch. An invalid seckey returns without clearing the scalar, since secp256k1_scalar_set_b32_seckey leaves nothing worth protecting there.
Adds comprehensive tests for the public DLEQ API: - secp256k1_dleq_prove(): Tests valid proof generation, NULL parameter detection, invalid secret key handling, context validation, that the proof verifies against the returned C, and that C is zeroed on every failure path - secp256k1_dleq_verify(): Tests valid verification, NULL parameter detection for all required inputs (proof, A, B, C), rejection of an e or s that overflows the group order, and that a proof verifies only against the message it was bound to. Verification is also checked against a static context, since it needs no precomputed generator table. Adds the BIP-374 v0.3.1 test vectors (9 generation + 12 verification cases that use secp256k1's generator), run through the public API: - include test_vectors_generate_proof.csv and test_vectors_verify_proof.csv, and modify .gitignore to explicitly allow csv files in `src/modules/dleq/` - tools/test_vectors_dleq_generate.py converts them to vectors.h, keeping only rows whose point_G is secp256k1's G - each generation vector's proof verifies against the returned C - the generation vector with B at infinity only checks that parsing B fails, since a secp256k1_pubkey cannot represent it Co-authored-by: stratospher <44024636+stratospher@users.noreply.github.com>
Adds an example for the dleq module, framed as a hardware signer proving to a watch-only coordinator that it derived an ECDH share honestly, the use case BIP-374 was written for.
afb2c9f to
9b5e5e6
Compare
|
Rebased on master and folded the VERIFY_CHECK commit into the respective commits. No functional changes since afb2c9f other than updating the test vectors to v0.3.1. |
This PR adds a DLEQ (Discrete Logarithm Equality) proof module as specified in BIP 374.
Based on PR #1651 by @stratospher and the secp256k1-zkp implementation.
Public API
Exposes two functions for proof generation and verification:
secp256k1_dleq_provesecp256k1_dleq_verifyThese are designed to support rust FFI bindings and follow the API patterns established in similar modules.
Questions for Reviewers
Notes
Addressed outstanding comments from PR #1651: