From 65093e1444e8e90e0cf3166633602bf17d2dd028 Mon Sep 17 00:00:00 2001 From: mllwchrry Date: Mon, 10 Aug 2026 11:29:49 +0300 Subject: [PATCH] surjection: prevent s-value reuse for different proof inputs The s-values produced by secp256k1_surjection_genrand previously depended only on their indices and the difference between input_blinding_key and output_blinding_key. Calls with the same difference therefore reused s-values even when their proof inputs differed. For proofs with the same used-input selection and honest input index, the same generated s-value was used as the signing nonce. Reusing this nonce across different proof messages allowed recovery of the blinding-key difference. The remaining repeated s-values also revealed the honest input index. This commit affects proof generation only; verification is unchanged. --- src/modules/surjection/main_impl.h | 15 ++- src/modules/surjection/surjection.h | 2 +- src/modules/surjection/surjection_impl.h | 47 +++++--- src/modules/surjection/tests_impl.h | 143 +++++++++++++++++++++++ 4 files changed, 192 insertions(+), 15 deletions(-) diff --git a/src/modules/surjection/main_impl.h b/src/modules/surjection/main_impl.h index 968cc477..80dbd7f3 100644 --- a/src/modules/surjection/main_impl.h +++ b/src/modules/surjection/main_impl.h @@ -337,7 +337,20 @@ int secp256k1_surjectionproof_generate(const secp256k1_context* ctx, secp256k1_s rsizes[0] = (int) n_used_pubkeys; indices[0] = (int) ring_input_index; secp256k1_surjection_genmessage(hash_ctx, msg32, ephemeral_input_tags, n_total_pubkeys, ephemeral_output_tag); - if (secp256k1_surjection_genrand(hash_ctx, borromean_s, n_used_pubkeys, &blinding_key) == 0) { + /* Derive every s-value, including the one used as the signing nonce, from + * every proof-relevant input to + * secp256k1_surjectionproof_generate. Except with negligible hash-collision + * probability, this prevents distinct proof inputs from reusing any + * s-value. + * + * The proof-relevant arguments to secp256k1_surjectionproof_generate + * correspond as follows: proof supplies n_total_pubkeys (proof->n_inputs), + * proof->used_inputs, and n_used_pubkeys, while proof->data is output and + * proof->initialized is VERIFY-only validation state; ephemeral_input_tags + * is committed by msg32; n_ephemeral_input_tags equals n_total_pubkeys as + * checked above; ephemeral_output_tag is committed by msg32; input_index + * and both blinding keys are passed directly. */ + if (secp256k1_surjection_genrand(hash_ctx, borromean_s, n_used_pubkeys, n_total_pubkeys, proof->used_inputs, msg32, input_index, input_blinding_key, output_blinding_key) == 0) { return 0; } /* Borromean sign will overwrite one of the s values we just generated, so use diff --git a/src/modules/surjection/surjection.h b/src/modules/surjection/surjection.h index ac7407d7..70b886ae 100644 --- a/src/modules/surjection/surjection.h +++ b/src/modules/surjection/surjection.h @@ -12,7 +12,7 @@ SECP256K1_INLINE static int secp256k1_surjection_genmessage(unsigned char *msg32, secp256k1_ge *ephemeral_input_tags, size_t n_input_tags, secp256k1_ge *ephemeral_output_tag); -SECP256K1_INLINE static int secp256k1_surjection_genrand(secp256k1_scalar *s, size_t ns, const secp256k1_scalar *blinding_key); +SECP256K1_INLINE static int secp256k1_surjection_genrand(const secp256k1_hash_ctx *hash_ctx, secp256k1_scalar *s, size_t ns, size_t n_inputs, const unsigned char *used_inputs, const unsigned char *msg32, size_t input_index, const unsigned char *input_blinding_key, const unsigned char *output_blinding_key); SECP256K1_INLINE static int secp256k1_surjection_compute_public_keys(secp256k1_gej *pubkeys, size_t n_pubkeys, const secp256k1_ge *input_tags, size_t n_input_tags, const unsigned char *used_tags, const secp256k1_ge *output_tag, size_t input_index, size_t *ring_input_index); diff --git a/src/modules/surjection/surjection_impl.h b/src/modules/surjection/surjection_impl.h index bccced52..cdf7714b 100644 --- a/src/modules/surjection/surjection_impl.h +++ b/src/modules/surjection/surjection_impl.h @@ -35,31 +35,52 @@ SECP256K1_INLINE static void secp256k1_surjection_genmessage(const secp256k1_has secp256k1_sha256_clear(&sha256_en); } -SECP256K1_INLINE static int secp256k1_surjection_genrand(const secp256k1_hash_ctx *hash_ctx, secp256k1_scalar *s, size_t ns, const secp256k1_scalar *blinding_key) { +/* Derive the ring's s-values, one of which is used as the signing nonce, from a + * seed that hashes the passed-in arguments. See the call site for how these + * correspond to the proof inputs. */ +SECP256K1_INLINE static int secp256k1_surjection_genrand(const secp256k1_hash_ctx *hash_ctx, secp256k1_scalar *s, size_t ns, size_t n_inputs, const unsigned char *used_inputs, const unsigned char *msg32, size_t input_index, const unsigned char *input_blinding_key, const unsigned char *output_blinding_key) { size_t i; - unsigned char sec_input[36]; + size_t used_inputs_len; + unsigned char n_inputs_ser[4]; + unsigned char index_ser[4]; + unsigned char counter[4]; + unsigned char seed[32]; + unsigned char out[32]; secp256k1_sha256 sha256_en; + used_inputs_len = (n_inputs + 7) / 8; + secp256k1_write_be32(n_inputs_ser, (uint32_t)n_inputs); + secp256k1_write_be32(index_ser, (uint32_t)input_index); + + /* Hash the arguments into the seed. */ + secp256k1_sha256_initialize(&sha256_en); + secp256k1_sha256_write(hash_ctx, &sha256_en, n_inputs_ser, 4); + secp256k1_sha256_write(hash_ctx, &sha256_en, used_inputs, used_inputs_len); + secp256k1_sha256_write(hash_ctx, &sha256_en, msg32, 32); + secp256k1_sha256_write(hash_ctx, &sha256_en, index_ser, 4); + secp256k1_sha256_write(hash_ctx, &sha256_en, input_blinding_key, 32); + secp256k1_sha256_write(hash_ctx, &sha256_en, output_blinding_key, 32); + secp256k1_sha256_finalize(hash_ctx, &sha256_en, seed); + secp256k1_sha256_clear(&sha256_en); + /* compute s values */ - secp256k1_scalar_get_b32(&sec_input[4], blinding_key); for (i = 0; i < ns; i++) { int overflow = 0; - sec_input[0] = i; - sec_input[1] = i >> 8; - sec_input[2] = i >> 16; - sec_input[3] = i >> 24; - + secp256k1_write_be32(counter, (uint32_t)i); secp256k1_sha256_initialize(&sha256_en); - secp256k1_sha256_write(hash_ctx, &sha256_en, sec_input, 36); - secp256k1_sha256_finalize(hash_ctx, &sha256_en, sec_input); + secp256k1_sha256_write(hash_ctx, &sha256_en, counter, 4); + secp256k1_sha256_write(hash_ctx, &sha256_en, seed, 32); + secp256k1_sha256_finalize(hash_ctx, &sha256_en, out); secp256k1_sha256_clear(&sha256_en); - secp256k1_scalar_set_b32(&s[i], sec_input, &overflow); + secp256k1_scalar_set_b32(&s[i], out, &overflow); if (overflow == 1) { - secp256k1_memclear_explicit(sec_input, 32); + secp256k1_memclear_explicit(out, sizeof(out)); + secp256k1_memclear_explicit(seed, sizeof(seed)); return 0; } } - secp256k1_memclear_explicit(sec_input, 32); + secp256k1_memclear_explicit(out, sizeof(out)); + secp256k1_memclear_explicit(seed, sizeof(seed)); return 1; } diff --git a/src/modules/surjection/tests_impl.h b/src/modules/surjection/tests_impl.h index 7ba328c3..0680a2ab 100644 --- a/src/modules/surjection/tests_impl.h +++ b/src/modules/surjection/tests_impl.h @@ -643,6 +643,147 @@ static void test_gen_verify_all(void) { test_gen_verify(SECP256K1_SURJECTIONPROOF_MAX_N_INPUTS, SECP256K1_SURJECTIONPROOF_MAX_USED_INPUTS); } +static int surjection_genrand_streams_equal(const secp256k1_scalar *a, const secp256k1_scalar *b, size_t n) { + size_t i; + for (i = 0; i < n; i++) { + if (!secp256k1_scalar_eq(&a[i], &b[i])) { + return 0; + } + } + return 1; +} + +static int surjection_genrand_stream_all_differ(const secp256k1_scalar *a, const secp256k1_scalar *b, size_t n) { + size_t i; + for (i = 0; i < n; i++) { + if (secp256k1_scalar_eq(&a[i], &b[i])) { + return 0; + } + } + return 1; +} + +/* Test that changing any proof-relevant argument to + * secp256k1_surjectionproof_generate changes every s-value produced by + * secp256k1_surjection_genrand. */ +static void test_surjection_genrand(void) { + const secp256k1_hash_ctx *hash_ctx = secp256k1_get_hash_context(CTX); + const size_t ns = 4; + const size_t n_inputs = 5; + unsigned char msg_a[32]; + unsigned char msg_b[32]; + unsigned char ikey_a[32]; + unsigned char ikey_b[32]; + unsigned char okey_a[32]; + unsigned char okey_b[32]; + unsigned char used_a[SECP256K1_SURJECTIONPROOF_MAX_N_INPUTS / 8] = { 0 }; + unsigned char used_b[SECP256K1_SURJECTIONPROOF_MAX_N_INPUTS / 8] = { 0 }; + secp256k1_scalar s_base[4]; + secp256k1_scalar s_cmp[4]; + + /* Baseline inputs, plus a one-bit variant of each. */ + testrand256(msg_a); + memcpy(msg_b, msg_a, 32); + msg_b[0] ^= 0x01; + testrand256(ikey_a); + memcpy(ikey_b, ikey_a, 32); + ikey_b[0] ^= 0x01; + testrand256(okey_a); + memcpy(okey_b, okey_a, 32); + okey_b[0] ^= 0x01; + + /* Two used-input bitmaps with the same popcount but a different set. */ + used_a[0] = 0x0f; /* inputs {0,1,2,3} */ + used_b[0] = 0x17; /* inputs {0,1,2,4} */ + + /* Baseline. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_base, ns, n_inputs, used_a, msg_a, 0, ikey_a, okey_a) == 1); + + /* Determinism: identical arguments reproduce the identical stream. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, n_inputs, used_a, msg_a, 0, ikey_a, okey_a) == 1); + CHECK(surjection_genrand_streams_equal(s_base, s_cmp, ns)); + + /* The message is bound. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, n_inputs, used_a, msg_b, 0, ikey_a, okey_a) == 1); + CHECK(surjection_genrand_stream_all_differ(s_base, s_cmp, ns)); + + /* The used-input selection is bound. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, n_inputs, used_b, msg_a, 0, ikey_a, okey_a) == 1); + CHECK(surjection_genrand_stream_all_differ(s_base, s_cmp, ns)); + + /* The total input count is bound even at the same bitmap byte length: + * n_inputs = 8 and n_inputs = 5 both use a one-byte bitmap. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, 8, used_a, msg_a, 0, ikey_a, okey_a) == 1); + CHECK(surjection_genrand_stream_all_differ(s_base, s_cmp, ns)); + + /* The honest input index is bound. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, n_inputs, used_a, msg_a, 1, ikey_a, okey_a) == 1); + CHECK(surjection_genrand_stream_all_differ(s_base, s_cmp, ns)); + + /* The input blinding key is bound. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, n_inputs, used_a, msg_a, 0, ikey_b, okey_a) == 1); + CHECK(surjection_genrand_stream_all_differ(s_base, s_cmp, ns)); + + /* The output blinding key is bound. */ + CHECK(secp256k1_surjection_genrand(hash_ctx, s_cmp, ns, n_inputs, used_a, msg_a, 0, ikey_a, okey_b) == 1); + CHECK(surjection_genrand_stream_all_differ(s_base, s_cmp, ns)); +} + +/* Changing an unused input tag changes the proof message. Check that every + * s-value changes and that both proofs verify. */ +static void test_surjectionproof_generate_changes_s_values(void) { + const size_t n_inputs = 3; + const size_t n_used = 2; + unsigned char seed[32]; + unsigned char input_blinding_key[3][32] = {{ 0 }}; + unsigned char output_blinding_key[32] = { 0 }; + unsigned char reblind[32] = { 0 }; + secp256k1_fixed_asset_tag fixed_input_tags[3]; + secp256k1_generator ephemeral_input_tags[3]; + secp256k1_generator ephemeral_output_tag; + secp256k1_surjectionproof proof_a; + secp256k1_surjectionproof proof_b; + size_t input_index; + size_t unused_index = n_inputs; + size_t i; + + testrand256(seed); + for (i = 0; i < n_inputs; i++) { + testrand256(fixed_input_tags[i].data); + input_blinding_key[i][31] = (unsigned char)i + 1; + CHECK(secp256k1_generator_generate_blinded(CTX, &ephemeral_input_tags[i], fixed_input_tags[i].data, input_blinding_key[i])); + } + output_blinding_key[31] = 4; + reblind[31] = 5; + CHECK(secp256k1_generator_generate_blinded(CTX, &ephemeral_output_tag, fixed_input_tags[1].data, output_blinding_key)); + + CHECK(secp256k1_surjectionproof_initialize(CTX, &proof_a, &input_index, fixed_input_tags, n_inputs, n_used, &fixed_input_tags[1], 100, seed) > 0); + CHECK(input_index == 1); + CHECK(secp256k1_surjectionproof_n_used_inputs(CTX, &proof_a) == n_used); + proof_b = proof_a; + + for (i = 0; i < n_inputs; i++) { + if (!(proof_a.used_inputs[i / 8] & (1 << (i % 8)))) { + unused_index = i; + } + } + CHECK(unused_index < n_inputs); + + CHECK(secp256k1_surjectionproof_generate(CTX, &proof_a, ephemeral_input_tags, n_inputs, &ephemeral_output_tag, input_index, input_blinding_key[input_index], output_blinding_key) == 1); + CHECK(secp256k1_surjectionproof_verify(CTX, &proof_a, ephemeral_input_tags, n_inputs, &ephemeral_output_tag) == 1); + + /* Regenerate the unused ephemeral input tag with reblind instead of + * input_blinding_key[unused_index]. The modified tag is the only proof input + * that differs between the two secp256k1_surjectionproof_generate calls. */ + CHECK(secp256k1_generator_generate_blinded(CTX, &ephemeral_input_tags[unused_index], fixed_input_tags[unused_index].data, reblind)); + CHECK(secp256k1_surjectionproof_generate(CTX, &proof_b, ephemeral_input_tags, n_inputs, &ephemeral_output_tag, input_index, input_blinding_key[input_index], output_blinding_key) == 1); + CHECK(secp256k1_surjectionproof_verify(CTX, &proof_b, ephemeral_input_tags, n_inputs, &ephemeral_output_tag) == 1); + + for (i = 0; i < n_used; i++) { + CHECK(secp256k1_memcmp_var(&proof_a.data[32 + 32 * i], &proof_b.data[32 + 32 * i], 32) != 0); + } +} + /* --- Test registry --- */ static const struct tf_test_entry tests_surjection[] = { CASE1(test_surjectionproof_api), @@ -651,6 +792,8 @@ static const struct tf_test_entry tests_surjection[] = { CASE1(test_input_selection_all), CASE1(test_input_selection_distribution), CASE1(test_gen_verify_all), + CASE1(test_surjection_genrand), + CASE1(test_surjectionproof_generate_changes_s_values), CASE1(test_no_used_inputs_verify), CASE1(test_bad_serialize), CASE1(test_bad_parse),