Clear Schnorr accumulator and 4x64 multiplication product #1966

pull benma-agent wants to merge 2 commits into bitcoin-core:master from benma-agent:benma-agent/schnorrsig-accumulator-wipe changing 2 files +3 −0
  1. benma-agent commented at 6:38 PM on October 14, 2026: none

    Schnorr signing and 4x64 scalar multiplication leave local temporaries uncleared after their last use. These temporaries can retain information from which the secret key can be recovered if an attacker can read the residual memory.

    • Clear the Schnorr signing accumulator e after its final use. On success it holds the public signature scalar, but with a valid keypair and a failing nonce callback or a nonce that reduces to zero, signing continues with the dummy nonce k = 1. The signature output is erased on failure while e retains 1 + challenge * sk modulo the group order. Given the message and public key, this value permits key recovery when the challenge is nonzero.
    • Clear the 4x64 scalar multiplication buffer l[8] immediately after reduction. It contains the full 512-bit product and is separate from the signing accumulator. During ordinary successful Schnorr signing, it can retain challenge * sk, which also permits key recovery given the public nonzero challenge.

    Both commits use the existing compiler-resistant clearing primitives. The accumulator change applies across scalar backends; the multiplication-buffer change is specific to 4x64.

  2. schnorrsig: clear the signing accumulator
    The signing accumulator e holds the public signature scalar on success,
    but that value must remain secret when signing fails.
    
    With a valid keypair, a failing nonce callback or a nonce that reduces to
    zero still runs the signing arithmetic using the dummy nonce k = 1. The
    signature output is cleared before returning failure, but e retained
    1 + challenge * sk modulo the curve order. Recovering this value from
    memory, together with the message and public key, allows recovery of the
    secret key whenever the challenge is nonzero.
    
    Clear e unconditionally after its last use with secp256k1_scalar_clear,
    which uses the existing compiler-resistant memory clearing primitive.
    c348001dc7
  3. scalar: clear the 4x64 multiplication temporary
    The 4x64 scalar multiplication implementation stores the full 512-bit
    product in a local l[8] array before reducing it modulo the group order.
    Reduction does not erase this array, so secret-derived product data can
    remain on the stack after multiplication returns.
    
    For example, Schnorr signing multiplies the public challenge by the
    secret key. Clearing the signing accumulator does not erase this
    separate copy of the unreduced product, which can expose the key if
    recovered from memory together with the public challenge.
    
    Clear l immediately after reduction with secp256k1_memclear_explicit.
    The wipe is unconditional and uses the existing compiler-resistant
    primitive, without changing the multiplication result or aliasing
    behavior. This addresses the 4x64 product array, not every intermediate
    or compiler-generated copy elsewhere in scalar arithmetic.
    87ddfa0fd2
  4. benma commented at 6:40 PM on October 14, 2026: contributor

    This issue was found and the PR created by my agent using GPT-6-Astra.

  5. fametrano commented at 8:28 PM on October 14, 2026: contributor

    Concept ACK. The 8x32 backend has the same temporary: secp256k1_scalar_mul in scalar_8x32_impl.h keeps the 512-bit product in l[16] and does not clear it. That backend is the one used on 32-bit targets without a 128-bit integer type, so the second commit could clear l there as well.

  6. Yudis-bit commented at 5:51 AM on October 15, 2026: contributor

    Clearing e in secp256k1_schnorrsig_sign_internal makes sense. On signing failure (ret == 0), the dummy nonce path computes with k = 1 and relies on secp256k1_memczero(sig64, 64, !ret) to avoid returning e = k + e_hash * sk, which would expose sk = (e - 1) * e_hash^(-1) mod n. Zeroing the output buffer while leaving e on the stack leaves that secret behind. Clearing it brings schnorrsig in line with secp256k1_ecdsa_sig_sign, which already clears its scalar accumulator n.

    I'd drop the second commit rather than extending it to 8x32. secp256k1_scalar_mul is an inner-loop primitive called heavily in public contexts like signature verification and ecmult. Inserting secp256k1_memclear_explicit adds a memset and a full memory clobber barrier to every scalar multiplication across the library.

    Clearing l also doesn't wipe the actual arithmetic state. In the assembly path, the 512-bit product and reduction temporaries reside in registers (r8-r15, rax, rdx) and stack slots that aren't scrubbed on return, while the caller still holds the inputs and the reduced output. Intermediate scratch wiping has never been the contract for low-level field or scalar primitives; secret zeroization is managed at the caller/module boundary.

    Taking commit 1 and dropping commit 2 seems like the right move.

  7. fametrano commented at 8:47 AM on October 15, 2026: contributor

    On arm64 the clear costs nothing above noise: scalar_mul takes 22.2 ns on master and on this PR with 4x64, 56.9 ns versus 57.1 ns on 8x32 with l[16] also cleared, and verify and sign do not move. The clear is not dropped and calls no memset: scalar_mul_512 is an out-of-line call that writes the product to l on the stack, and the library's scalar_mul zeroes it with two 32-byte stores. Method: median of the best time over 7 interleaved Release runs per variant, 8x32 forced with SECP256K1_TEST_OVERRIDE_WIDE_MULTIPLY=int64; x86_64 and its assembly path not measured.

  8. Yudis-bit commented at 9:19 AM on October 15, 2026: contributor

    Even with minimal store overhead on arm64, wiping l does not establish a security boundary. In optimized builds, scalar_mul inlines both multiplication and reduction into a frame where intermediate limbs, carries, and reduction temporaries spill across multiple stack slots and volatile registers (r8-r15 on x86_64, x0-x30 on arm64). Zeroing the 64-byte l array leaves those spilled temporaries and register state unscrubbed on return, while the caller still holds both inputs and the reduced output.

    Inner-loop primitives cannot clean registers or caller frames, and they have no way to distinguish secret scalars from purely public ones in verification and ecmult. That is why libsecp256k1 enforces secret zeroization at the caller boundary—where the confidentiality context is actually known, as commit 1 does for e. Adding partial scratch zeroing inside arithmetic primitives introduces compiler barriers and maintenance baggage without providing real stack sanitization.

    Commit 1 is a clean, genuine fix; dropping commit 2 keeps the library's layer boundaries intact.

  9. theStack added the label side-channel on Oct 16, 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-10-16 18:15 UTC

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