field: correct `fe_equal` magnitude bound for `b` #1877

pull l0rinc wants to merge 1 commits into bitcoin-core:master from l0rinc:l0rinc/fe-equal-magnitude-bound changing 3 files +15 −2
  1. l0rinc commented at 12:43 PM on June 21, 2026: contributor

    Problem: While reviewing this area with Fable, I noticed that secp256k1_fe_equal(a, b) documented b up to magnitude 31. Review pointed out that the implementation negates a before adding b, so the actual internal bound is 30.

    Fix: Lower the documented and checked bound to 30. This keeps fe_equal unchanged at runtime and matches current callers, which use much smaller magnitudes.

    Test: Add a focused test with random field elements whose magnitudes are randomized within the accepted a <= 1 and b <= 30 bounds.

  2. in src/tests.c:3075 in c7914b1621
    3070 | +    secp256k1_fe_mul_int_unchecked(&b, 31);
    3071 | +#ifdef VERIFY
    3072 | +    CHECK(b.magnitude == 31);
    3073 | +#endif
    3074 | +    CHECK(secp256k1_fe_equal(&a, &b));
    3075 | +}
    


    theStack commented at 11:46 PM on June 21, 2026:

    Not really sure if we want to introduce those kind of magnitude-boundaries-tests in general (cc @real-or-random, @sipa), but if we do, I'd probably write this one as:

    static void run_fe_equal_magnitude_boundaries(void) {
        secp256k1_fe a, b;
    
        /* Set up elements with fe_equal's documented a and b magnitude bounds. */
        secp256k1_fe_set_int(&a, 1);
        secp256k1_fe_set_int(&b, 1);
        secp256k1_fe_mul_int(&b, 30);
    #ifdef VERIFY
        CHECK(a.magnitude == 1);
        CHECK(b.magnitude == 30);
    #endif
        CHECK(!secp256k1_fe_equal(&a, &b));
    }
    

    (Fable was very fixated on the zero values; probably to keep the original b value after the multiplication and make fe_equal return 1, but the return value doesn't matter for this test).


    l0rinc commented at 9:28 PM on June 23, 2026:

    Fable was very fixated on the zero values

    That's on me, I wanted to make this testable, the original version of Fable was this:

    <details><summary>field: correct secp256k1_fe_equal's max magnitude for b (31 -> 30)</summary>

    secp256k1_fe_equal(a, b) (src/field_impl.h) documents and asserts via
    SECP256K1_FE_VERIFY_MAGNITUDE that b may have magnitude up to 31
    (contract in src/field.h:166-171). But the body negates a (magnitude <= 1),
    and secp256k1_fe_negate sets the result magnitude to m+1 = 2
    (field_impl.h:303), then runs secp256k1_fe_add(&na, b), which requires
    r->magnitude + a->magnitude <= 32 (field_impl.h:326). With na at magnitude 2,
    b's true maximum is 30, not 31.
    
    The precondition is therefore self-contradictory: b at magnitude 31 satisfies
    the stated contract (SECP256K1_FE_VERIFY_MAGNITUDE(b, 31) passes) yet aborts the
    VERIFY build deep inside secp256k1_fe_add. The bound has been latent since the
    fe_add magnitude assertion was added: the contract comment that introduced "31"
    (7d7d43c6dd, 2022) predates that assertion, when a magnitude-33 intermediate was
    merely out-of-spec rather than an abort. This file is identical to
    bitcoin-core/secp256k1 master, so the off-by-one exists upstream too.
    
    Fix: tighten the asserted/documented maximum for b to its true value, 30. Every
    production caller passes b as a low-magnitude group-element coordinate or computed
    value (max SECP256K1_GE_X_MAGNITUDE_MAX = 4; the fe_sqrt verify path passes
    magnitude <= 8), so no caller is affected by the tightening.
    
    Repro of the boundary bug (pre-fix), standalone VERIFY build:
        secp256k1_fe a, b;
        secp256k1_fe_set_int(&a, 1);       /* magnitude 1  = documented max for a */
        secp256k1_fe_get_bounds(&b, 31);   /* magnitude 31 = documented max for b */
        secp256k1_fe_equal(&a, &b);
      $ clang -DVERIFY -I. -Iinclude -Isrc repro.c \
            src/precomputed_ecmult.c src/precomputed_ecmult_gen.c -o repro && ./repro
        a.mag=1 b.mag=31 (about to call fe_equal)
        ./src/field_impl.h:326: test condition failed: r->magnitude + a->magnitude <= 32
      -> SIGABRT (exit 134): an input the contract declares valid crashes in fe_add.
    
    Post-fix, same standalone:
      - b magnitude 31 is now rejected at fe_equal's OWN precondition:
        ./src/field_impl.h:167: test condition failed: a->magnitude <= m  (SIGABRT)
      - b magnitude 30 returns normally (exit 0).
    
    Verifier: new narrow regression test run_fe_equal_magnitude (src/tests.c,
    registered as CASE(fe_equal_magnitude)) exercises b at the exact maximum
    magnitude 30 for both the equal and unequal cases. Bumping its magnitude-30
    constructions to 31 reproduces the abort.
      $ ninja -C cmake-build-debug tests          # Debug build defines -DVERIFY
      $ ./cmake-build-debug/bin/tests -t=fe_equal_magnitude -log=1
        Test fe_equal_magnitude PASSED
      $ ./cmake-build-debug/bin/tests -i=2         # full suite, exit 0
      Non-VERIFY build also compiles, links and passes (#ifdef VERIFY guards the
      magnitude field accesses).
    
    Limitation: VERIFY-only. In non-VERIFY builds the assertions compile out and the
    limb representation has enough slack that the magnitude-33 intermediate does not
    overflow, so results stay correct; this is a contract/assertion off-by-one, not a
    runtime or security defect. src/field.h is an internal header, so there is no
    public-API or externally observable contract change.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    ---
     src/field.h      |  2 +-
     src/field_impl.h |  2 +-
     src/tests.c      | 43 +++++++++++++++++++++++++++++++++++++++++++
     3 files changed, 45 insertions(+), 2 deletions(-)
    
    diff --git a/src/field.h b/src/field.h
    index 945029ecd8..8b25d99b66 100644
    --- a/src/field.h
    +++ b/src/field.h
    @@ -166,7 +166,7 @@ static int secp256k1_fe_is_odd(const secp256k1_fe *a);
     /** Determine whether two field elements are equal.
      *
      * On input, a and b must be valid field elements with magnitudes not exceeding
    - * 1 and 31, respectively.
    + * 1 and 30, respectively.
      * Returns a = b (mod p).
      */
     static int secp256k1_fe_equal(const secp256k1_fe *a, const secp256k1_fe *b);
    diff --git a/src/field_impl.h b/src/field_impl.h
    index 7aa7de431a..19af6aa26f 100644
    --- a/src/field_impl.h
    +++ b/src/field_impl.h
    @@ -27,7 +27,7 @@ SECP256K1_INLINE static int secp256k1_fe_equal(const secp256k1_fe *a, const secp
         SECP256K1_FE_VERIFY(a);
         SECP256K1_FE_VERIFY(b);
         SECP256K1_FE_VERIFY_MAGNITUDE(a, 1);
    -    SECP256K1_FE_VERIFY_MAGNITUDE(b, 31);
    +    SECP256K1_FE_VERIFY_MAGNITUDE(b, 30);
     
         secp256k1_fe_negate(&na, a, 1);
         secp256k1_fe_add(&na, b);
    diff --git a/src/tests.c b/src/tests.c
    index 6c3cd39f2f..8b64a5fb05 100644
    --- a/src/tests.c
    +++ b/src/tests.c
    @@ -3059,6 +3059,48 @@ static int fe_equal(const secp256k1_fe *a, const secp256k1_fe *b) {
         return secp256k1_fe_equal(&an, &bn);
     }
     
    +/* secp256k1_fe_equal(a, b) accepts a with magnitude up to 1 and b with magnitude
    + * up to 30 (see the contract in src/field.h). It negates a (raising its magnitude
    + * to 2) and adds b, and secp256k1_fe_add requires the operand magnitudes to sum to
    + * at most 32, so b's true maximum magnitude is 30, not 31. This pins that exact
    + * boundary: at magnitude 30 the comparison must work and return the correct
    + * result. Bumping the magnitude-30 constructions below to 31 aborts inside
    + * secp256k1_fe_add (./src/field_impl.h: r->magnitude + a->magnitude <= 32) under
    + * VERIFY, which is the boundary bug this guards against. */
    +static void run_fe_equal_magnitude(void) {
    +    secp256k1_fe a, b, zero_mag29;
    +    int i;
    +
    +    /* zero_mag29 = 0 (mod p) carried at magnitude 29; adding it to a magnitude-1
    +     * element leaves the value unchanged but raises its magnitude to 30. */
    +    secp256k1_fe_set_int(&zero_mag29, 0);
    +    secp256k1_fe_negate(&zero_mag29, &zero_mag29, 0); /* 0 (mod p), magnitude 1 */
    +    secp256k1_fe_mul_int_unchecked(&zero_mag29, 29);  /* 0 (mod p), magnitude 29 */
    +#ifdef VERIFY
    +    CHECK(zero_mag29.magnitude == 29);
    +#endif
    +
    +    for (i = 1; i <= 16; i++) {
    +        secp256k1_fe_set_int(&a, i);
    +
    +        /* Equal case: b has the same value as a, carried at magnitude 30. */
    +        secp256k1_fe_set_int(&b, i);
    +        secp256k1_fe_add(&b, &zero_mag29);
    +#ifdef VERIFY
    +        CHECK(a.magnitude == 1 && b.magnitude == 30);
    +#endif
    +        CHECK(secp256k1_fe_equal(&a, &b));
    +
    +        /* Unequal case: b differs from a, still carried at magnitude 30. */
    +        secp256k1_fe_set_int(&b, i + 1);
    +        secp256k1_fe_add(&b, &zero_mag29);
    +#ifdef VERIFY
    +        CHECK(b.magnitude == 30);
    +#endif
    +        CHECK(!secp256k1_fe_equal(&a, &b));
    +    }
    +}
    +
     static void run_field_convert(void) {
         static const unsigned char b32[32] = {
             0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
    @@ -7964,6 +8006,7 @@ static const struct tf_test_entry tests_scalar[] = {
     static const struct tf_test_entry tests_field[] = {
         CASE(field_half),
         CASE(field_misc),
    +    CASE(fe_equal_magnitude),
         CASE(field_convert),
         CASE(field_be32_overflow),
         CASE(fe_mul),
    

    </details>

  3. theStack commented at 12:02 AM on June 22, 2026: contributor

    Quite an interesting find. When introducing the boundaries for fe_equal, it was apparently overlooked that the fe_negate call increases the magnitude of a (setting it to m+1, i.e. always to 2 in this case), so in light of the following fe_add call requirements the correct maximum magnitude for b is indeed 30 rather than 31.

    Review help wanted: I would appreciate review on whether this is a real problem in practice and whether preserving the current bound is the right tradeoff.

    Seems merely an internal documentation hickup and not a real problem. Given that as of now the maximum magnitude of all b passed to fe_equal is only 7 [1], I think there is no point to enforce the currently documented bound with modified run-time behavior. Lowering the documented bound to 30 and adapting the check accordingly should be fine.

    [1] evaluated with the following hacky patch and running the tests (and assuming that we do cover all relevant production code API paths in our tests):

    index 7aa7de43..f06753e8 100644
    --- a/src/field_impl.h
    +++ b/src/field_impl.h
    @@ -28,6 +28,13 @@ SECP256K1_INLINE static int secp256k1_fe_equal(const secp256k1_fe *a, const secp
         SECP256K1_FE_VERIFY(b);
         SECP256K1_FE_VERIFY_MAGNITUDE(a, 1);
         SECP256K1_FE_VERIFY_MAGNITUDE(b, 31);
    +#ifdef VERIFY
    +    static int max_b_magnitude = 0;
    +    if (b->magnitude > max_b_magnitude) {
    +        max_b_magnitude = b->magnitude;
    +        printf("new max magnitude for b passed to fe_equal: %d\n", max_b_magnitude);
    +    }
    +#endif
     
         secp256k1_fe_negate(&na, a, 1);
         secp256k1_fe_add(&na, b);
    
  4. real-or-random commented at 6:40 AM on June 22, 2026: contributor

    Alternative: One possible fix is to lower the documented bound from 31 to 30, which would avoid adding work to fe_equal.

    I agree with @theStack that this is probably the way to go.

  5. l0rinc renamed this:
    RFC: field: make `fe_equal` handle its documented bound
    field: correct fe_equal's b magnitude bound
    on Jun 23, 2026
  6. l0rinc renamed this:
    field: correct fe_equal's b magnitude bound
    field: correct `fe_equal` magnitude bound for `b`
    on Jun 23, 2026
  7. l0rinc force-pushed on Jun 23, 2026
  8. l0rinc marked this as ready for review on Jun 23, 2026
  9. l0rinc commented at 9:30 PM on June 23, 2026: contributor

    Thanks for the review @theStack, @real-or-random, @furszy.

    I pushed an updated version that follows the suggested approach: lowered the documented and checked b magnitude bound from 31 to 30, and replaced the previous magnitude-31 test with a small boundary test for the accepted a=1, b=30 case.

  10. in src/tests.c:3072 in 766d70dae4
    3067 | +    secp256k1_fe_set_int(&b, 1);
    3068 | +    secp256k1_fe_mul_int(&b, 30);
    3069 | +#ifdef VERIFY
    3070 | +    CHECK(a.magnitude == 1);
    3071 | +    CHECK(b.magnitude == 30);
    3072 | +#endif
    


    real-or-random commented at 6:52 AM on June 24, 2026:
        testutil_random_fe_magnitude(&a, 1);
        testutil_random_fe_magnitude(&b, 30);
    

    theStack commented at 12:14 PM on June 24, 2026:

    nit: If we're setting the magnitudes randomly, it might make sense to do multiple iterations (e.g. 100 * COUNT, as in run_fe_mul) to increase the chance of hitting the 1/30 bounds in a single test run.


    l0rinc commented at 12:57 PM on June 24, 2026:

    Thanks, used testutil_random_fe_magnitude() for both operands over 100 * COUNT iterations.


    real-or-random commented at 3:09 PM on June 24, 2026:

    Oh right, I had missed that testutil_random_fe_magnitude uses a random magnitude instead of a fixed one. And yes, with 100 * COUNT, we'll get a nice test in the end.

  11. real-or-random commented at 6:53 AM on June 24, 2026: contributor

    ACK mod my suggestion

  12. l0rinc force-pushed on Jun 24, 2026
  13. real-or-random commented at 3:09 PM on June 24, 2026: contributor

    @l0rinc CI doesn't like your latest push.

  14. real-or-random added the label bug on Jun 24, 2026
  15. real-or-random added the label assurance on Jun 24, 2026
  16. l0rinc commented at 3:52 PM on June 24, 2026: contributor

    @l0rinc CI doesn't like your latest push.

    Could we maybe use a stable compiler version on CI to avoid these? Or is that deliberate? Regardless, I tried to fix it, please let me know if it makes sense.

  17. l0rinc closed this on Jun 24, 2026

  18. l0rinc reopened this on Jun 24, 2026

  19. l0rinc closed this on Jun 24, 2026

  20. l0rinc reopened this on Jun 24, 2026

  21. theStack commented at 5:20 PM on June 24, 2026: contributor

    @l0rinc: Seems you were lucky enough to encounter two independent CI failures after pushing. I think the second commit (which looks like it fixes one of them) deserves its own PR, as it is not related to the field-related changes. Your question

    Could we maybe use a stable compiler version on CI to avoid these? Or is that deliberate? Regardless, I tried to fix it, please let me know if it makes sense.

    could then be also discussed there.

  22. l0rinc force-pushed on Jun 24, 2026
  23. l0rinc commented at 5:33 PM on June 24, 2026: contributor

    I think the second commit (which looks like it fixes one of them) deserves its own PR

    https://github.com/bitcoin-core/secp256k1/pull/1880

  24. real-or-random referenced this in commit 9e3a165ad0 on Jun 25, 2026
  25. real-or-random commented at 9:31 AM on June 25, 2026: contributor

    Could we maybe use a stable compiler version on CI to avoid these? Or is that deliberate? Regardless, I tried to fix it, please let me know if it makes sense.

    Sorry that CI is creating headaches here. This was really an unfortunate coincidence. In this case, the warning is perfectly right. Passing a pointer to the stack to free() is UB, but sure, it has nothing to do with this PR.

    And yes, using unreleased compilers is deliberate. We'd like to catch compiler changes affecting our code negatively before the compiler is released. This includes compiler bugs obviously, but also new performance "optimizations" that break our constant-time tricks.

    The hassle this has created so far is pretty low. The most interesting breakage was that we found an internal compiler error in a new arithmetic optimization in GCC. Dealing with this is annoying to us, but if compilers choke on our code (or even miscompile it), it's also something we want to deal with.

  26. real-or-random commented at 2:45 PM on June 25, 2026: contributor

    Rebasing on master should make CI happy now.

  27. field: correct fe_equal's b magnitude bound
    `secp256k1_fe_equal` negates `a` before adding `b`.
    That gives the temporary value magnitude 2, and the following field addition requires the input magnitudes to sum to at most 32.
    So the largest `b` magnitude the implementation can accept is 30, not 31.
    
    Lower the documented and checked bound for `b` to 30.
    Adjust the focused test to use random field elements with randomized magnitudes within the accepted `a <= 1` and `b <= 30` bounds.
    
    Co-authored-by: Sebastian Falbesoner <sebastian.falbesoner@gmail.com>
    Co-authored-by: Tim Ruffing <me@real-or-random.org>
    994b35010d
  28. l0rinc force-pushed on Jun 25, 2026
  29. theStack approved
  30. theStack commented at 6:57 PM on June 25, 2026: contributor

    ACK 994b35010d1d62383be0ec970582ff4a171f7e84

  31. real-or-random approved
  32. real-or-random commented at 6:38 AM on June 26, 2026: contributor

    utACK 994b35010d1d62383be0ec970582ff4a171f7e84

  33. real-or-random merged this on Jun 26, 2026
  34. real-or-random closed this on Jun 26, 2026


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-06-27 00:15 UTC

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