Add DLEQ proof implementing BIP 374 #1802

pull macgyver13 wants to merge 5 commits into bitcoin-core:master from macgyver13:dleq-module-standalone changing 19 files +1099 −19
  1. macgyver13 commented at 1:39 AM on January 15, 2026: contributor

    This PR adds a DLEQ (Discrete Logarithm Equality) proof module as specified in BIP 374.

    Based on [PR #1651](https://github.com/bitcoin-core/secp256k1/pull/1651) by @stratospher and the secp256k1-zkp implementation.

    Public API

    Exposes two functions for proof generation and verification:

    • secp256k1_dleq_prove
    • secp256k1_dleq_verify

    These are designed to support rust FFI bindings and follow the API patterns established in similar modules.

    Questions for Reviewers

    • Should this module be optional (current behavior) or enabled by default?
    • Are there additional changes needed to support existing Silent Payments PRs?
    • Feedback on API design, documentation, and test coverage?

    Notes

    Addressed outstanding comments from [PR #1651](https://github.com/bitcoin-core/secp256k1/pull/1651#discussion_r2417300085):

    • BIP-374 v0.2.0 test vectors
    • Proper memory clearing with memclear
  2. furszy commented at 3:01 AM on January 15, 2026: member

    Have you talked with @stratospher before opening this PR? I don’t want to draw conclusions prematurely, but opening a parallel PR that implements the same changes without first engaging on the existing one by reviewing it, leaving a comment, or waiting for the author's public response is not how we usually collaborate in open source. The lack of (co)authorship on the commits is also unusual.

    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.

  3. macgyver13 commented at 3:22 AM on January 15, 2026: contributor

    Have you talked with @stratospher before opening this PR?

    Yes, stratospher and I had discussed who should submit this new PR. I drew the short straw :)

  4. macgyver13 marked this as a draft on Jan 15, 2026
  5. macgyver13 force-pushed on Jan 15, 2026
  6. macgyver13 marked this as ready for review on Jan 15, 2026
  7. in src/modules/dleq/main_impl.h:63 in 30d6f5e883
      58 | +}
      59 | +
      60 | +static int secp256k1_dleq_hash_point(secp256k1_sha256 *sha, secp256k1_ge *p) {
      61 | +    unsigned char buf[33];
      62 | +    size_t size = 33;
      63 | +    /* Reject infinity point */
    


    stratospher commented at 7:32 AM on January 16, 2026:

    2457f89d: I guess this comment might be applicable now since the public API takes in secp256k1_pubkey and the ge we get is now from there - so unlikely for it to be infinity and VERIFY_CHECK might be better. though if we think about a function in isolation - it makes sense to reject infinity point.

    cc @theStack


    macgyver13 commented at 8:55 PM on January 25, 2026:

    45911b2c attempts to handle the 'invalid proof (e.g., all zeros)' case more clearly by moving the infinity check from secp256k1_dleq_hash_point to secp256k1_dleq_verify_internal, where R1 and R2 are computed. The check now occurs before calling secp256k1_dleq_challenge.

    A check is required: invalid proofs (e.g., all zeros) will generate R1 and R2 as infinity points, which would cause verification to fail at VERIFY_CHECK(!secp256k1_ge_is_infinity(elem)); during serialization.

    Open to a better solution.

  8. in src/modules/dleq/main_impl.h:98 in 30d6f5e883
      93 | +            masked_key[i] = key32[i] ^ ZERO_MASK[i];
      94 | +        }
      95 | +    }
      96 | +
      97 | +    secp256k1_nonce_function_bip374_sha256_tagged(&sha);
      98 | +    /* Hash masked-key||msg||m using the tagged hash as per BIP-374 v0.2.0 */
    


    stratospher commented at 7:36 AM on January 16, 2026:

    2457f89d: you could clarify that msg contains A and C. and that's different from m.

  9. in src/modules/dleq/main_impl.h:109 in 30d6f5e883
     104 | +    secp256k1_sha256_finalize(&sha, nonce32);
     105 | +    secp256k1_sha256_clear(&sha);
     106 | +    secp256k1_memclear_explicit(masked_key, sizeof(masked_key));
     107 | +}
     108 | +
     109 | +/* Generates a nonce as defined in BIP0374 v0.2.0 */
    


    stratospher commented at 7:40 AM on January 16, 2026:

    2457f89d: not sure if we should mention the exact version - we don't mention the version for other modules like musig for example. only difference in newer version is the randomness computation in proof generation anyways.

  10. in src/modules/dleq/main_impl.h:281 in 30d6f5e883
     276 | +
     277 | +    secp256k1_dleq_pair(&ctx->ecmult_gen_ctx, &A, &C, &a, &B);
     278 | +
     279 | +    ret = secp256k1_dleq_prove_internal(ctx, &s, &e, &a, &B, &A, &C, aux_rand32, msg);
     280 | +    if (!ret) {
     281 | +        secp256k1_scalar_clear(&a);
    


    stratospher commented at 7:49 AM on January 16, 2026:

    078d2da: since we always clear a - we can move it up and out of the if condition.

  11. in src/modules/dleq/tests_impl.h:179 in 30d6f5e883
     174 | +        const unsigned char *m = NULL;
     175 | +
     176 | +        if (i > 2 && i < 6) {
     177 | +            /* Skip tests indices 3-5: proof generation failure cases (a=0, a=N, B=infinity).
     178 | +            * These contain placeholder data from test_vectors_generate_proof.csv that would
     179 | +            * fail to parse. Only indices 0-2 and 6-12 have valid test data. 
    


    stratospher commented at 8:00 AM on January 16, 2026:

    nit: 2614b2a5: extra white space

  12. in src/modules/dleq/tests_impl.h:195 in 30d6f5e883 outdated
     190 | +
     191 | +        if (is_not_empty(msg_bytes[i])) {
     192 | +            m = msg_bytes[i];
     193 | +        }
     194 | +        
     195 | +        CHECK(secp256k1_dleq_verify_internal(&s, &e, &A, &B, &C, m) == success[i]);
    


    stratospher commented at 8:01 AM on January 16, 2026:

    nit: 2614b2a5: extra white space in blank line above

  13. in src/modules/dleq/tests_impl.h:234 in 30d6f5e883
     229 | +    /* Check dleq verify input validation */
     230 | +    CHECK_ILLEGAL(CTX, secp256k1_dleq_verify(CTX, NULL, &A, &B, &C, msg));
     231 | +    CHECK_ILLEGAL(CTX, secp256k1_dleq_verify(CTX, proof, NULL, &B, &C, msg));
     232 | +    CHECK_ILLEGAL(CTX, secp256k1_dleq_verify(CTX, proof, &A, NULL, &C, msg));
     233 | +    CHECK_ILLEGAL(CTX, secp256k1_dleq_verify(CTX, proof, &A, &B, NULL, msg));
     234 | +    
    


    stratospher commented at 8:02 AM on January 16, 2026:

    nit: 9149b57a: extra white space in blank line

  14. in src/modules/dleq/Makefile.am.include:2 in 30d6f5e883
       0 | @@ -0,0 +1,3 @@
       1 | +include_HEADERS += include/secp256k1_dleq.h
       2 | +noinst_HEADERS += src/modules/dleq/dleq_vectors.h
    


    stratospher commented at 8:05 AM on January 16, 2026:

    2614b2a5: tests also need to be added. nit: slight ordering preference for main_impl before test vectors - you can refer some other makefile.

  15. in tools/test_vectors_dleq_generate.py:99 in 30d6f5e883
      94 | +with open(sys.argv[1] + "/test_vectors_verify_proof.csv", newline='') as csvfile:
      95 | +    reader = csv.DictReader(csvfile)
      96 | +    for _ in range(5):  # Skip the first 5 rows since those test vectors don't use secp's generator point
      97 | +        next(reader, None)
      98 | +
      99 | +    for i in range(3):  
    


    stratospher commented at 8:07 AM on January 16, 2026:

    nit: 2614b2a5: extra white space

  16. in src/modules/dleq/main_impl.h:11 in 2457f89d8a outdated
       6 | +#ifndef SECP256K1_MODULE_DLEQ_MAIN_H
       7 | +#define SECP256K1_MODULE_DLEQ_MAIN_H
       8 | +
       9 | +#include "../../../include/secp256k1.h"
      10 | +#include "../../../include/secp256k1_dleq.h"
      11 | +
    


    stratospher commented at 12:27 PM on January 16, 2026:

    2457f89d: #include "../../hash.h"

  17. in src/modules/dleq/tests_impl.h:9 in 2614b2a52b outdated
       0 | @@ -0,0 +1,204 @@
       1 | +/***********************************************************************
       2 | + * Distributed under the MIT software license, see the accompanying    *
       3 | + * file COPYING or https://www.opensource.org/licenses/mit-license.php.*
       4 | + ***********************************************************************/
       5 | +
       6 | +#ifndef SECP256K1_MODULE_DLEQ_TESTS_H
       7 | +#define SECP256K1_MODULE_DLEQ_TESTS_H
       8 | +
       9 | +#include "dleq_vectors.h"
    


    stratospher commented at 12:49 PM on January 16, 2026:

    2614b2a5: #include "../../unit_test.h"

  18. stratospher commented at 11:44 AM on January 17, 2026: contributor

    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?

  19. qatkk commented at 10:06 AM on January 18, 2026: none

    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. Is there a specific reason why the second generator B is provided directly as an input? @furszy In the original proposal of DLEQ proofs by Chaum and Pedersen [1], the correctness of the protocol relies on the prover not knowing the discrete‑log relation between the two generators. Specifically, if the prover knows a scalar b such that B=b⋅G, then the two equations for the verification are no longer distinct and will be dependent on each other. In the context of silent payments (and the description provided in BIP 374) this makes sense, since B represents another user’s public key — a point for which the prover does not know the corresponding private key. However, as commented by stratospher #1651 (comment) this PR is intended to support more general DLEQ use cases, I am concerned that allowing callers to supply an arbitrary B might introduce risks in scenarios where generator independence is not guaranteed.

    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 G and B. I was wondering whether a similar approach might help avoid potential misuse here, or whether this falls outside the intended scope of this PR. I would be very interested to hear your thoughts on this. [1] D. Chaum and T. P. Pedersen, Wallet Databases with Observers, 1992.
https://www.hsslb.ch/cryptopapers/other/Chaum_WalletDBswObservers.pdf

  20. real-or-random added the label feature on Jan 23, 2026
  21. macgyver13 force-pushed on Jan 25, 2026
  22. real-or-random commented at 1:24 PM on January 30, 2026: contributor

    In the original proposal of DLEQ proofs by Chaum and Pedersen [1], the correctness of the protocol relies on the prover not knowing the discrete‑log relation between the two generators.

    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.

    Specifically, if the prover knows a scalar b such that B=b⋅G, then the two equations for the verification are no longer distinct and will be dependent on each other.

    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?

  23. macgyver13 commented at 10:28 PM on February 2, 2026: contributor

    Thank you for the review @stratospher and appreciate the curious questions about the API!

    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.

    If we accept C as an input, I would suggest computing C internally to verify correctness before calling secp256k1_dleq_prove_internal. This would counter the optimization benefit. This is the approach I took when testing that variation of the API.

    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 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 C as input parameters to proof generation.

    DLEQ implementations reviewed:

    Related protocols reviewed (not DLEQ-based):

    • Curve Trees - anonymous usage tokens from curve trees
    • RIDDLE - ring signatures (LSAG) with key images

    I don't see a compelling reason to change the API based on these findings, but I'm open to different perspectives.

  24. qatkk commented at 3:42 PM on February 10, 2026: none

    Thanks for your response and for the explanation.

    You probably mean soundness instead of correctness?

    Yes — thanks for the correction.

    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…

    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. Writing out the equations explicitly for the single-curve setting makes it clear that this independence assumption is not needed here, as you point out.

    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?

    My reasoning was that the second verification equation (the one checking R₂ in this notation) is effectively just the first verification equation multiplied by b, due to how the parameters are constructed. This made me think that knowing the discrete logarithm between G and B might collapse the two checks.

  25. macgyver13 force-pushed on Jul 29, 2026
  26. macgyver13 commented at 9:46 PM on July 29, 2026: contributor

    Rebased on latest master and adapted the module to two API changes it introduced, no behavior changes.

    • the pluggable SHA256 hash_ctx
    • the ecmult_gen → ecmult_gen_ge split

    Folded into the affected commits:

    • dleq: add module structure and internal implementation
    • dleq: add test framework and BIP-374 test vectors
    • dleq: add public API wrappers
  27. in src/modules/dleq/main_impl.h:270 in ad77238c15
     265 | +    ARG_CHECK(pubkey_B != NULL);
     266 | +
     267 | +    secp256k1_scalar_set_b32(&a, seckey32, &overflow);
     268 | +    if (overflow || secp256k1_scalar_is_zero(&a)) {
     269 | +        return 0;
     270 | +    }
    


    theStack commented at 11:33 PM on August 16, 2026:

    could use secp256k1_scalar_set_b32_seckey here which checks for both zero and overflow (and returns 0 if either of them hit), so no extra variable and call to _scalar_is_zero is needed

        if (!secp256k1_scalar_set_b32_seckey(&a, seckey32)) {
            return 0;
        }
    
  28. in src/modules/dleq/main_impl.h:25 in 2aab3dc815
      20 | +    sha->s[4] = 0x977ab0a0ul;
      21 | +    sha->s[5] = 0xcb8e2740ul;
      22 | +    sha->s[6] = 0x60bb4b81ul;
      23 | +    sha->s[7] = 0x68a41b66ul;
      24 | +
      25 | +    sha->bytes = 64;
    


    theStack commented at 11:49 PM on August 16, 2026:

    here and for the other two sha256 init functions below: could use secp256k1_sha256_initialize_midstate (introduced in PR #1825, commit f48b1bfa5d40a4d7303b196017d2e298520d1066), see other modules that use BIP340 tagged hashes

  29. in src/modules/dleq/tests_impl.h:63 in c8f676a5cf
      58 | +        CHECK(secp256k1_dleq_prove_internal(CTX, &s, &e, &a, &B, &A, &C, aux_rand, (i & 1) ? msg : NULL) == 1);
      59 | +        CHECK(secp256k1_dleq_verify_internal(secp256k1_get_hash_context(CTX), &s, &e, &A, &B, &C, (i & 1) ? msg : NULL) == 1);
      60 | +        secp256k1_scalar_set_b32(&s, proof_64, &overflow);
      61 | +        VERIFY_CHECK(overflow == 0);
      62 | +        secp256k1_scalar_set_b32(&e, proof_64 + 32, &overflow);
      63 | +        VERIFY_CHECK(overflow == 0);
    


    theStack commented at 12:47 AM on August 17, 2026:

    in this test, proof_64 is initialized with all-zero on declaration is never written to later, i.e. it stays unchanged, I guess that was not intended?

  30. in src/modules/dleq/main_impl.h:112 in 2aab3dc815 outdated
     107 | +}
     108 | +
     109 | +/* Generates a nonce as defined in BIP0374 v0.2.0 */
     110 | +static int secp256k1_dleq_nonce(const secp256k1_hash_ctx *hash_ctx, secp256k1_scalar *k, const unsigned char *a32, const unsigned char *A_33, const unsigned char *C_33, const unsigned char *aux_rand32, const unsigned char *m) {
     111 | +    unsigned char buf[66];
     112 | +    unsigned char nonce[32];
    


    theStack commented at 12:52 AM on August 17, 2026:

    in secp256k1_dleq_nonce: should clear out the nonce buffer before exiting via _memclear_explicit (see e.g. the schnorrsig module)

  31. macgyver13 force-pushed on Aug 20, 2026
  32. macgyver13 commented at 6:19 PM on August 20, 2026: contributor

    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:

    • Changed secp256k1_dleq_hash_point to return void. It always returned 1 and went unchecked by callers.
    • Constant-time coverage: dleq_prove is now exercised in ctime_tests.c, and A/C are declassified as the proof's public statement. Invalid seckeys return early via secp256k1_scalar_set_b32_seckey + declassify, taking the same "constant-time only for valid inputs" approach as #1919. Re-ordered pubkey_load(B) ahead of the seckey parse in secp256k1_dleq_prove, so an invalid B returns before the secret scalar is loaded, removing the need to clear it on that path.
    • Enabled the module by default in dev mode, so the release job's --enable-dev-mode distcheck builds it. Previously nothing in CI validated the module against the tarball.
    • Extended the public API tests with message-binding cases in both directions.
    • Rounded out failure-path coverage: zero nonces, infinity points, invalid pubkeys, scalar overflow.

    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.

  33. macgyver13 force-pushed on Aug 20, 2026
  34. macgyver13 commented at 8:05 PM on August 20, 2026: contributor

    My last local rebase missed #1915, which moved these helpers to group.h and renamed them:

    • secp256k1_eckey_pubkey_parse -> secp256k1_ge_parse_ext33
    • secp256k1_eckey_pubkey_serialize33 -> secp256k1_ge_serialize_ext33
  35. real-or-random commented at 10:43 AM on August 25, 2026: contributor

    This was discussed in yesterday's IRC meeting:

    08:39 < theStack> one topic i wanted to raise is dleq proofs and their scope within libsecp 08:40 < theStack> the main use case now would be users of silentpayments (pr #1651), but there is also an open pr with a dedicated module (pr #1802) 08:43 < real_or_random> hm it ticks the first two boxes here for sure https://github.com/bitcoin-core/secp256k1/blob/master/CONTRIBUTING.md#adding-new-functionality-or-modules 08:44 < real_or_random> relevance: well. it's used within SP but that is not an argument because then internal code would suffice. what are other use cases? 08:44 < real_or_random> I tend to think DLEQ is generic enough to be relevant. It came up in multiple contexts so far 08:45 < real_or_random> it's a middle-layer thing like ECDH. Not as useful as ECDH in terms of number of applications but there are a handful? (...) 08:50 < theStack> other than SP, i'm currently not aware of any concrete applications for DLEQs in the bitcoin ecosystem. i guess my preference is to extend the SP module with a create_outputs_with_proof function and not expose the DLEQ functions unless there are compelling use cases 01:47 < real_or_random> theStack: my thinking was that joinmarket needs DLEQ for example. #1802 has a short list. but sure, they all have their own implementations currently 01:48 < real_or_random> theStack: but agreed. not exposing it for now is the conservative thing. it can always be exposed if there's demand

  36. in src/modules/dleq/main_impl.h:261 in fa49e6d355
     256 | +    }
     257 | +
     258 | +    is_sec_valid = secp256k1_scalar_set_b32_seckey(&a, seckey32);
     259 | +    secp256k1_declassify(ctx, &is_sec_valid, sizeof(is_sec_valid));
     260 | +    if (!is_sec_valid) {
     261 | +        secp256k1_scalar_clear(&a);
    


    theStack commented at 1:07 PM on August 25, 2026:

    nit: this line doesn't hurt, but is not strictly needed I think; if the secret is invalid, there is no need to protect it from leaking in the first place

  37. in src/ctime_tests.c:113 in fa49e6d355
     106 | @@ -103,6 +107,10 @@ static void run_tests(secp256k1_context *ctx, unsigned char *key) {
     107 |      unsigned char ellswift[64];
     108 |      static const unsigned char prefix[64] = {'t', 'e', 's', 't'};
     109 |  #endif
     110 | +#ifdef ENABLE_MODULE_DLEQ
     111 | +    secp256k1_pubkey dleq_pubkey_B;
     112 | +    unsigned char dleq_proof[64];
     113 | +#endif
    


    theStack commented at 1:09 PM on August 25, 2026:

    nit: could place this blocks below the ENABLE_MODULE_SILENTPAYMENTS one to be consistent with ordering

  38. in src/modules/dleq/main_impl.h:162 in 2b8d0093f3
     157 | +    /* Reject infinity points */
     158 | +    if (secp256k1_ge_is_infinity(B) || secp256k1_ge_is_infinity(A) || secp256k1_ge_is_infinity(C)) {
     159 | +        return 0;
     160 | +    }
     161 | +    secp256k1_scalar_get_b32(a32, a);
     162 | +    secp256k1_ge_serialize_ext33(B_33, B);
    


    theStack commented at 1:21 PM on August 25, 2026:

    B_33 is unused, so this line and the declaration above can be removed

  39. in include/secp256k1_dleq.h:46 in 2b8d0093f3
      41 | +    const unsigned char *seckey32,
      42 | +    const secp256k1_pubkey *pubkey_B,
      43 | +    const unsigned char *aux_rand32,
      44 | +    const unsigned char *msg
      45 | +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3)
      46 | +  SECP256K1_ARG_NONNULL(4);
    


    theStack commented at 1:43 PM on August 25, 2026:

    pedantic consistency nit: not sure how much we care, but these are usually all on a single line

  40. in src/modules/dleq/main_impl.h:164 in 2b8d0093f3
     159 | +        return 0;
     160 | +    }
     161 | +    secp256k1_scalar_get_b32(a32, a);
     162 | +    secp256k1_ge_serialize_ext33(B_33, B);
     163 | +    secp256k1_ge_serialize_ext33(A_33, A);
     164 | +    secp256k1_ge_serialize_ext33(C_33, C);
    


    theStack commented at 1:50 PM on August 25, 2026:

    at this point we already ensured that A and C are not infinity, so could use the regular non-infinity serialize functions instead:

        secp256k1_ge_serialize33(A, A_33);
        secp256k1_ge_serialize33(C, C_33);
    
  41. in src/modules/dleq/main_impl.h:219 in 2b8d0093f3
     214 | +
     215 | +    secp256k1_scalar_negate(&e_neg, e);
     216 | +    /* R1 = s*G - e*A */
     217 | +    secp256k1_ecmult(&R1j, &Aj, &e_neg, s);
     218 | +    /* R2 = s*B - e*C */
     219 | +    secp256k1_ecmult(&tmpj, &Cj, &e_neg, &secp256k1_scalar_zero);
    


    theStack commented at 2:33 PM on August 25, 2026:

    here and the other _ecmult call two lines below: passing NULL instead secp256k1_scalar_zero is slightly faster (see #1834)

        secp256k1_ecmult(&tmpj, &Cj, &e_neg, NULL);
    
  42. in include/secp256k1_dleq.h:29 in 2b8d0093f3 outdated
      24 | + *  Proves knowledge of scalar a such that A = a*G and C = a*B without
      25 | + *  revealing a.
      26 | + *
      27 | + *  Returns: 1 if proof generation succeeded
      28 | + *           0 if nonce generation failed (negligible probability) or
      29 | + *             if any input is invalid
    


    theStack commented at 2:52 PM on August 25, 2026:

    Could mention here that this function doesn't strictly follow BIP-374, as it doesn't do immediate verification after proof generation (which is fine and makes sense for performance reasons). See e.g. the wording in the Schnorr signing function docs which would also work here: https://github.com/bitcoin-core/secp256k1/blob/1c8babcd6c76dbea50bf4d65468dd8088d1b27a6/include/secp256k1_schnorrsig.h#L97-L99

  43. in src/modules/dleq/main_impl.h:105 in 2b8d0093f3
     100 | +    secp256k1_memclear_explicit(nonce, sizeof(nonce));
     101 | +    return ret;
     102 | +}
     103 | +
     104 | +/* Generates a challenge as defined in BIP0374 */
     105 | +static void secp256k1_dleq_challenge(const secp256k1_hash_ctx *hash_ctx, secp256k1_scalar *e, secp256k1_ge *B, secp256k1_ge *R1, secp256k1_ge *R2, secp256k1_ge *A, secp256k1_ge *C, const unsigned char *m) {
    


    theStack commented at 3:00 PM on August 25, 2026:

    pedantic nit: could rearrange the parameters to match the order in which they are actually hashed (i.e. A, B, C, R1, R2, m), slightly more straight-forward to read I think


    macgyver13 commented at 1:20 PM on August 26, 2026:

    I like this suggestion. Both secp256k1_dleq_prove_internal and secp256k1_dleq_verify_internal have s before e. I was on the fence about changing these in the last round but think it makes sense here too. Will push in next update.

  44. in src/modules/dleq/main_impl.h:45 in 2b8d0093f3
      40 | +    secp256k1_sha256_initialize_midstate(sha, 64, midstate);
      41 | +}
      42 | +
      43 | +static void secp256k1_dleq_hash_point(const secp256k1_hash_ctx *hash_ctx, secp256k1_sha256 *sha, secp256k1_ge *p) {
      44 | +    unsigned char buf[33];
      45 | +    secp256k1_ge_serialize_ext33(buf, p);
    


    theStack commented at 3:01 PM on August 25, 2026:

    afaict there should never be call-sites where point-at-infinity is possible, so

        secp256k1_ge_serialize33(p, buf);
    
  45. in src/modules/dleq/main_impl.h:233 in 2b8d0093f3
     228 | +    secp256k1_ge_set_gej(&R1, &R1j);
     229 | +    secp256k1_ge_set_gej(&R2, &R2j);
     230 | +    secp256k1_dleq_challenge(hash_ctx, &e_expected, B, &R1, &R2, A, C, m);
     231 | +
     232 | +    secp256k1_scalar_add(&e_expected, &e_expected, &e_neg);
     233 | +    return secp256k1_scalar_is_zero(&e_expected);
    


    theStack commented at 3:06 PM on August 25, 2026:

    shorter and reads more naturally (e_neg is still needed for the multiplications above though):

        return secp256k1_scalar_eq(e, &e_expected);
    
  46. in src/modules/dleq/main_impl.h:203 in 2b8d0093f3
     198 | + *          A : point on the curve(a⋅G) computed from a
     199 | + *          B : point on the curve
     200 | + *          C : point on the curve(a⋅B) computed from a
     201 | + *          m : optional message
     202 | + * */
     203 | +static int secp256k1_dleq_verify_internal(const secp256k1_hash_ctx *hash_ctx, secp256k1_scalar *s, secp256k1_scalar *e, secp256k1_ge *A, secp256k1_ge *B, secp256k1_ge *C, const unsigned char *m) {
    


    theStack commented at 3:09 PM on August 25, 2026:

    const-correctness nit: the two scalars are not modified inside and thus could be const

    static int secp256k1_dleq_verify_internal(const secp256k1_hash_ctx *hash_ctx, const secp256k1_scalar *s, const secp256k1_scalar *e, secp256k1_ge *A, secp256k1_ge *B, secp256k1_ge *C, const unsigned char *m) {
    

    (for the _ge paramters we unfortunately can't apply that as they might be modified on serialization; we could create local copies of them to work around that, but not sure if it's worth it)

  47. in tools/test_vectors_dleq_generate.py:107 in 84bca3c609
     102 | +with open(sys.argv[1] + "/test_vectors_verify_proof.csv", newline="") as csvfile:
     103 | +    reader = csv.DictReader(csvfile)
     104 | +    for _ in range(
     105 | +        5
     106 | +    ):  # Skip the first 5 rows since those test vectors don't use secp's generator point
     107 | +        next(reader, None)
    


    theStack commented at 3:48 PM on August 25, 2026:

    could ensure here that these test cases indeed have a generator point different from secp256k1's to avoid skipping cases that would be possible to test

  48. theStack commented at 4:10 PM on August 25, 2026: contributor

    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.py is missing the executable flag
    • an entry in the features list of README.md is missing
    • adding an example examples/dleq.c would 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.

  49. macgyver13 commented at 12:09 AM on August 26, 2026: contributor

    Thanks @theStack for the detailed review and @real-or-random for relaying the meeting notes. Both of these are helpful context. I'll work through the review points and push an update to address your suggestions.

    On scope: I've been reflecting on this today, and I've come round to thinking the safer and more pragmatic approach for BIP375 is a small set of silent payments functions, ahead of the two DLEQ primitives this PR exposes. They'd give wallet developers a more natural implementation boundary. Using the current two primitives leaves the developer to handle several BIP352 and BIP375 details themselves. That's related to your examples/dleq.c point, since an example can demonstrate the primitives but can't carry these rules for the caller:

    • taproot even-Y negation on input keys
    • the input_hash construction
    • the k-ordering rule: group by scan key, order by spend key within the group, then by output index (the module groups by scan key today; handling the full ordering there would be ideal)

    The shape follows from BIP375 having two forms of share/proof, a global one over every eligible input key and a per-input one, plus a verifier holding no key for the inputs it checks and a combiner holding none at all. Listed below in order of how much key material the caller holds, starting with @stratospher's existing function for context:

    /* 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](/bitcoin-core-secp256k1/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 deriving outputs and proofs together shares the one ECDH per unique scan key that both need. The other three are illustrative. The last one keys each share by its scan pubkey, matching how BIP375 keys the share fields themselves.

    One question came out of reading #1651 to work out how the above would fit alongside it. In sender_create_outputs_with_proof, seckey_sum_scalar is multiplied by input_hash before create_shared_secret_with_proof is called, so the value that ends up as the DLEQ secret is input_hash·a_n rather than a_n. The proof is then a statement about input_hash·a_n, and the stored shared_secret is input_hash·a_n·B_scan.

    If I'm reading BIP375 correctly, the value it wants in PSBT_GLOBAL_SP_ECDH_SHARE is a_n·B_scan, with the proof over a_n and input_hash applied afterwards during output computation, so a proof made the other way wouldn't verify against A_n. Am I missing a reason it's folded in earlier? I can see why it happens in master's sender_create_outputs, where the comment at main_impl.h:277 notes that multiplying the scalars first saves an elliptic curve multiplication and nothing observable depends on the ordering, so I assume it carried over naturally once proofs were added.

    For what it's worth, create_shared_secret_with_proof itself looks agnostic here: handed a_n it would produce C = a_n·B_scan and A = a_n·G directly. So if this does want changing for BIP375, it looks like the caller rather than the proof code. @theStack once you've had a chance to look at these signatures we can work out who's best placed to integrate them into the silent payments module. I can see a future where both the silent payments functions and the DLEQ primitives are useful.

    I am still in the exploratory phases of new work that combines MuSig2 and Silent Payments and uses the existing DLEQ API. Of the four above, only the combiner _sender_create_outputs_from_shares carries over to that case; share creation and verification still want the generic DLEQ pair. Once that work is ready for critique I can share it for review.

  50. theStack commented at 5:42 PM on August 27, 2026: contributor

    @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: https://github.com/bitcoin-core/secp256k1/issues/1925

  51. brunoerg commented at 12:58 PM on August 28, 2026: contributor

    Mutation testing report for this PR is available at: https://secp256k1.space/pull/1802

  52. dleq: add module structure and internal implementation
    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 and verification both reject infinity points, and the
    secret-derived buffers (the masked key, the nonce, and the serialized
    secret scalar a) are cleared before returning.
    
    - Rearrange e and s in prove_internal and verify_internal to match
      serialization order
    - Arrange dleq_challenge parameters in hash order
    - Compare the challenge with secp256k1_scalar_eq instead of negate-and-
      test-for-zero
    - Use &ctx->hash_ctx directly (#1908 removed the get_hash_context()
      accessor)
    - Pass NULL rather than secp256k1_scalar_zero for the unused G scalar
      in secp256k1_ecmult
    
    Co-authored-by: stratospher <44024636+stratospher@users.noreply.github.com>
    a0bbd318ff
  53. dleq: add test framework and BIP-374 test vectors
    - Adds test coverage for DLEQ internal functions
    - BIP-374 official test vectors (6 generation + 13 verification cases)
    - Includes Python script for generating test vectors matching BIP-374
    specification.
    - test_vectors_dleq_generate.py checks each row's point_G against
    secp256k1's G explicitly, instead of assuming the first 5 rows of
    each CSV don't use it
    - include generate_proof.csv and verify_proof.csv - modify gitignore to
    explicitly allow csv files in `src/modules/dleq/`
    
    Tests call *_internal functions directly. Public API tests will be added in a subsequent commit.
    
    Co-authored-by: stratospher <44024636+stratospher@users.noreply.github.com>
    29ad4dbf81
  54. dleq: add public API wrappers
    Adds public API functions that wrap the internal DLEQ implementation:
    
    - secp256k1_dleq_prove(): Generate DLEQ proof from secret key and base point B.
      Computes A = a*G and C = a*B internally, then generates proof.
    
    - 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.
    0c9e876ea8
  55. dleq: add public API tests
    Adds comprehensive tests for the public DLEQ API:
    
    - secp256k1_dleq_prove(): Tests valid proof generation, NULL parameter
      detection, invalid secret key handling, context validation
    
    - secp256k1_dleq_verify(): Tests valid verification, NULL parameter
      detection for all required inputs (proof, A, B, C), rejection of an
      all-zero proof, 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.
    49e684929a
  56. ci: enable dleq module a95485640b
  57. macgyver13 force-pushed on Aug 31, 2026
  58. macgyver13 commented at 4:39 PM on August 31, 2026: contributor

    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 test_vectors_generate_proof.csv and test_vectors_verify_proof.csv, this required modifying .gitignore to explicitly allow *.csv files from /src/modules/dleq/.

    Rebased on master and adapted to #1908 (direct ->hash_ctx access).

    Verified generator produced same output after G point check instead of relying on row count (no diff):

    ./tools/test_vectors_dleq_generate.py src/modules/dleq | git diff --no-index -- - src/modules/dleq/dleq_vectors.h
    
  59. in include/secp256k1_dleq.h:35 in a0bbd318ff
      30 | + *
      31 | + *  Returns: 1 if proof generation succeeded
      32 | + *           0 if nonce generation failed (negligible probability) or
      33 | + *             if any input is invalid
      34 | + *
      35 | + *  Args:        ctx: pointer to a context object
    


    theStack commented at 5:57 PM on September 8, 2026:
     *  Args:        ctx: pointer to a context object (not secp256k1_context_static)
    

    as this function needs a context for generator point multiplication (the suggested change is the API docs convention also used in other modules)

  60. in src/modules/dleq/main_impl.h:129 in a0bbd318ff
     124 | +static void secp256k1_dleq_pair(const secp256k1_ecmult_gen_context *ecmult_gen_ctx, secp256k1_ge *A, secp256k1_ge *C, const secp256k1_scalar *a, const secp256k1_ge *B) {
     125 | +    secp256k1_gej Cj;
     126 | +
     127 | +    secp256k1_ecmult_gen_ge(ecmult_gen_ctx, A, a);
     128 | +    secp256k1_ecmult_const(&Cj, B, a);
     129 | +    secp256k1_ge_set_gej(C, &Cj);
    


    theStack commented at 6:04 PM on September 8, 2026:

    should clear the Jacobian result Cj here for side-channel resistance

    (unrelated to this PR, but we could maybe introduce a secp256k1_ecmult_const_ge variant that does the Jacobian->affine conversion and Jacobian clearing internally to simplify, similar to what we did for generator point multiplication in #1861).

  61. in README.md:26 in a0bbd318ff
      22 | @@ -23,6 +23,7 @@ Features:
      23 |  * Optional module for ElligatorSwift key exchange according to [BIP-324](https://github.com/bitcoin/bips/blob/master/bip-0324.mediawiki).
      24 |  * Optional module for MuSig2 Schnorr multi-signatures according to [BIP-327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki).
      25 |  * Optional module for Silent Payments sending and receiving according to [BIP-352](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki).
      26 | +* Optional module for DLEQ according to [BIP-374](https://github.com/bitcoin/bips/blob/master/bip-0374.mediawiki).
    


    theStack commented at 6:17 PM on September 8, 2026:
    * Optional module for DLEQ proofs according to [BIP-374](https://github.com/bitcoin/bips/blob/master/bip-0374.mediawiki).
    
  62. in include/secp256k1_dleq.h:71 in a0bbd318ff
      66 | +    const secp256k1_context *ctx,
      67 | +    const unsigned char *proof64,
      68 | +    const secp256k1_pubkey *pubkey_A,
      69 | +    const secp256k1_pubkey *pubkey_B,
      70 | +    const secp256k1_pubkey *pubkey_C,
      71 | +    const unsigned char *msg
    


    theStack commented at 6:19 PM on September 8, 2026:

    nit, to follow the library convention (see e.g. Schnorr module)

        const unsigned char *msg32
    

    same for the prove function above

  63. in src/modules/dleq/main_impl.h:71 in a0bbd318ff
      66 | +            197, 228, 235, 145, 124, 59, 203, 21,
      67 | +            66, 88, 250, 253, 207, 123, 43, 55
      68 | +        };
      69 | +        for (i = 0; i < 32; i++) {
      70 | +            masked_key[i] = key32[i] ^ ZERO_MASK[i];
      71 | +        }
    


    theStack commented at 6:31 PM on September 8, 2026:

    could add a test that verifies that this precomputed array is correct, by calling the nonce function once with aux_rand32 being NULL and once with 32 zero bytes (see e.g. how it's done in the schnorrsig module: https://github.com/bitcoin-core/secp256k1/blob/978d3fa7f91996f7782db2cc4d112615ac93c867/src/modules/schnorrsig/tests_impl.h#L102-L106).

  64. in src/modules/dleq/main_impl.h:147 in a0bbd318ff
     142 | + *          A : point on the curve(a⋅G) generated from a
     143 | + *          C : point on the curve(a⋅B) generated from a
     144 | + * aux_rand32 : pointer to 32-byte auxiliary randomness used to generate the nonce in secp256k1_nonce_function_dleq.
     145 | + *          m : an optional message
     146 | + * */
     147 | +static int secp256k1_dleq_prove_internal(const secp256k1_context *ctx, secp256k1_scalar *e, secp256k1_scalar *s, const secp256k1_scalar *a, secp256k1_ge *B, secp256k1_ge *A, secp256k1_ge *C, const unsigned char *aux_rand32, const unsigned char *m) {
    


    theStack commented at 6:34 PM on September 8, 2026:

    nit: for better readability, I think it makes sense to name the message msg(32) for the internal functions as well

  65. theStack added the label needs-changelog on Sep 8, 2026
  66. theStack commented at 6:41 PM on September 8, 2026: contributor

    Left some more smaller findings below, and added the "needs changelog" label.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin-core/secp256k1. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-09-13 01:15 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me