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