git / moonbite-network / tests/security/test_poc_low_s_malleability.py · raw

"""PoC/regression for audit finding: high-S ECDSA malleability (legacy chain).(r, s) and (r, n - s) are both valid ECDSA signatures for the same message andkey. Because a tx's txid commits to its signatures, accepting high-S lets anyoneflip s and mutate the txid without the private key. Fix: the signer normalizesto low-S and verify() rejects high-S.Run: python -m pytest tests/security/test_poc_low_s_malleability.py -v"""from ecdsa import SECP256k1from transaction import (    Transaction, TxInput, TxOutput, canonical_low_s, generate_keypair,    pubkey_hash,)N = SECP256k1.orderdef _flip_s(sig_hex: str) -> str:    """Return the malleated high/low-S counterpart: s -> n - s."""    b = bytes.fromhex(sig_hex)    r, s = b[:32], int.from_bytes(b[32:], "big")    return (r + (N - s).to_bytes(32, "big")).hex()def _signed_tx():    sk, pubkey_hex = generate_keypair()    pkh = pubkey_hash(pubkey_hex)    prev = TxOutput(amount=1000, pubkey_hash=pkh)    tx = Transaction([TxInput("aa" * 32, 0)], [TxOutput(900, "bb" * 20)])    tx.sign_input(0, sk)    resolver = lambda txid, i: prev if (txid, i) == ("aa" * 32, 0) else None    return tx, resolverdef test_signer_always_emits_low_s():    # RFC 6979 yields high-S ~half the time; the signer must canonicalize every    # time, so many independent signings must all be low-S.    for _ in range(40):        tx, _ = _signed_tx()        s = int.from_bytes(bytes.fromhex(tx.inputs[0].signature)[32:], "big")        assert 0 < s <= N // 2def test_honest_signed_tx_still_verifies():    for _ in range(40):        tx, resolver = _signed_tx()        assert tx.verify(resolver) is Truedef test_high_s_variant_is_rejected():    tx, resolver = _signed_tx()    assert tx.verify(resolver) is True                 # canonical form accepted    tx.inputs[0].signature = _flip_s(tx.inputs[0].signature)  # malleate to high-S    assert tx.verify(resolver) is False                # non-canonical rejecteddef test_canonical_low_s_is_idempotent_and_correct():    # A deliberately high-S signature is pulled down; an already-low one is kept.    r = b"\x11" * 32    high = r + (N - 5).to_bytes(32, "big")    low = canonical_low_s(high)    assert int.from_bytes(low[32:], "big") == 5    assert canonical_low_s(low) == low