commit 8794de1c168bba2e98f3f822d99df3252fb36614
parent 031cb9775c8008a05835505107b6baf4d12e174c
Author: Jared Tobin <jared@jtobin.io>
Date: Sat, 16 May 2026 22:57:16 -0230
sighash: strip OP_CODESEPARATOR in legacy preimage
Bitcoin Core's legacy SignatureHash strips every OP_CODESEPARATOR
(0xab) from the scriptCode before hashing (see
CTransactionSignatureSerializer::SerializeScriptCode). We were
including them verbatim, producing consensus-incorrect sighashes for
any scriptCode containing 0xab in opcode position.
Add strip_codeseparators :: ByteString -> ByteString that walks
opcodes, properly skipping push-data sections (direct push,
OP_PUSHDATA1/2/4) so that 0xab bytes appearing inside data pushes
are preserved. Apply in serialize_legacy_sighash to the scriptCode
before it gets set on the signing input. BIP143 segwit path
unchanged - per spec, codeseparator trimming there is the caller's
responsibility.
Tests:
- Unit: no-op without 0xab; strips 0xab in opcode position;
preserves 0xab inside direct-push data; preserves 0xab inside
OP_PUSHDATA1 data while stripping a trailing 0xab opcode
- Property: idempotence on arbitrary bytestrings
- Property: no-op when input contains no 0xab byte
- Re-add Bitcoin Core sighash.json entry 14 (scriptCode contains
two OP_CODESEPARATOR bytes), which now passes
Diffstat:
2 files changed, 118 insertions(+), 3 deletions(-)
diff --git a/lib/Bitcoin/Prim/Tx/Sighash.hs b/lib/Bitcoin/Prim/Tx/Sighash.hs
@@ -21,6 +21,9 @@ module Bitcoin.Prim.Tx.Sighash (
-- * BIP143 Segwit Sighash
, sighash_segwit
+
+ -- * Internal
+ , strip_codeseparators
) where
import Bitcoin.Prim.Tx
@@ -39,7 +42,7 @@ import Data.Bits ((.&.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as BSB
import qualified Data.List.NonEmpty as NE
-import Data.Word (Word32, Word64)
+import Data.Word (Word8, Word32, Word64)
import GHC.Generics (Generic)
-- | Canonical sighash type flags.
@@ -108,6 +111,52 @@ hash256 :: BS.ByteString -> BS.ByteString
hash256 = SHA256.hash . SHA256.hash
{-# INLINE hash256 #-}
+-- | Strip @OP_CODESEPARATOR@ (0xab) opcodes from a script, skipping
+-- push-data sections so that data bytes equal to 0xab are preserved.
+--
+-- This is consensus-required preprocessing for the legacy sighash
+-- scriptCode (see Bitcoin Core's @CTransactionSignatureSerializer@).
+-- BIP143 segwit sighash does /not/ perform this stripping; for
+-- segwit, the caller is responsible for trimming the scriptCode to
+-- the portion after the last executed @OP_CODESEPARATOR@.
+--
+-- On a malformed script (truncated push data), the malformed tail is
+-- copied verbatim without further codeseparator processing.
+strip_codeseparators :: BS.ByteString -> BS.ByteString
+strip_codeseparators = BS.pack . go . BS.unpack
+ where
+ go :: [Word8] -> [Word8]
+ go [] = []
+ go (b : rest)
+ | b == 0xab = go rest
+ | b >= 0x01 && b <= 0x4b = push (fromIntegral b) [b] rest
+ | b == 0x4c = case rest of
+ (n : rest') -> push (fromIntegral n) [b, n] rest'
+ [] -> [b]
+ | b == 0x4d = case rest of
+ (n0 : n1 : rest') ->
+ let !len = fromIntegral n0
+ + fromIntegral n1 * 0x100
+ in push len [b, n0, n1] rest'
+ _ -> b : rest
+ | b == 0x4e = case rest of
+ (n0 : n1 : n2 : n3 : rest') ->
+ let !len = fromIntegral n0
+ + fromIntegral n1 * 0x100
+ + fromIntegral n2 * 0x10000
+ + fromIntegral n3 * 0x1000000
+ in push len [b, n0, n1, n2, n3] rest'
+ _ -> b : rest
+ | otherwise = b : go rest
+
+ -- | Copy a push header and N data bytes verbatim. If the script is
+ -- truncated, copy whatever's available and stop processing.
+ push :: Int -> [Word8] -> [Word8] -> [Word8]
+ push !len !header !rest =
+ let (chunk, rest') = splitAt len rest
+ in header ++ chunk ++
+ if length chunk == len then go rest' else []
+
-- legacy sighash -------------------------------------------------------------
-- | Compute legacy sighash for P2PKH/P2SH inputs.
@@ -152,7 +201,8 @@ serialize_legacy_sighash
-> Word32
-> BS.ByteString
serialize_legacy_sighash Tx{..} !idx !script_pubkey !ht =
- let !base = base_type ht
+ let !script' = strip_codeseparators script_pubkey
+ !base = base_type ht
!anyonecanpay = is_anyonecanpay ht
!inputs_list = NE.toList tx_inputs
!outputs_list = NE.toList tx_outputs
@@ -161,7 +211,7 @@ serialize_legacy_sighash Tx{..} !idx !script_pubkey !ht =
clear_scripts :: Int -> [TxIn] -> [TxIn]
clear_scripts !_ [] = []
clear_scripts !i (inp : rest)
- | i == idx = inp { txin_script_sig = script_pubkey } : clear_rest
+ | i == idx = inp { txin_script_sig = script' } : clear_rest
| otherwise = inp { txin_script_sig = BS.empty } : clear_rest
where
!clear_rest = clear_scripts (i + 1) rest
diff --git a/test/Main.hs b/test/Main.hs
@@ -61,11 +61,18 @@ main = defaultMain $
, testGroup "sighash" [
testGroup "legacy" [
sighash_legacy_minimal
+ , testGroup "codeseparators" [
+ codesep_no_op
+ , codesep_strip_simple
+ , codesep_inside_push
+ , codesep_inside_pushdata1
+ ]
, testGroup "Bitcoin Core sighash.json" [
bc_sighash_1
, bc_sighash_2
, bc_sighash_4
, bc_sighash_9
+ , bc_sighash_14
, bc_sighash_20
]
]
@@ -103,6 +110,8 @@ main = defaultMain $
, prop_sighash_legacy_acp_invariant
, prop_sighash_legacy_none_invariant
, prop_sighash_legacy_none_acp_invariant
+ , prop_strip_codesep_idempotent
+ , prop_strip_codesep_no_0xab_unchanged
]
]
]
@@ -1019,6 +1028,15 @@ bc_sighash_9 = bcSighashCase
(-1960128125)
"29aa6d2d752d3310eba20442770ad345b7f6a35f96161ede5f07b33e92053e2a"
+bc_sighash_14 :: TestTree
+bc_sighash_14 = bcSighashCase
+ "entry 14: idx=1, hashType=0x9604e295 (ALL|ACP, strips 2x 0xab)"
+ "f40a750702af06efff3ea68e5d56e42bc41cdb8b6065c98f1221fe04a325a898cb61f3d7ee030000000363acacffffffffb5788174aef79788716f96af779d7959147a0c2e0e5bfb6c2dba2df5b4b97894030000000965510065535163ac6affffffff0445e6fd0200000000096aac536365526a526aa6546b000000000008acab656a6552535141a0fd010000000000c897ea030000000008526500ab526a6a631b39dba3"
+ "00abab5163ac"
+ 1
+ (-1778064747)
+ "d76d0fc0abfa72d646df888bce08db957e627f72962647016eeae5a8412354cf"
+
bc_sighash_20 :: TestTree
bc_sighash_20 = bcSighashCase
"entry 20: idx=0, hashType=0xcab2f825 (ALL)"
@@ -1033,3 +1051,50 @@ bc_sighash_20 = bcSighashCase
(-894181723)
"8b300032a1915a4ac05cea2f7d44c26f2a08d109a71602636f15866563eaafdc"
+-- strip_codeseparators tests -------------------------------------------------
+
+-- A script containing 0x00, OP_1, OP_IF, OP_CHECKSIG and no 0xab. Strip
+-- should be a no-op.
+codesep_no_op :: TestTree
+codesep_no_op = H.testCase "no 0xab: unchanged" $
+ H.assertEqual "" (BS.pack [0x00, 0x51, 0x63, 0xac])
+ (strip_codeseparators (BS.pack [0x00, 0x51, 0x63, 0xac]))
+
+-- Two OP_CODESEPARATOR bytes in opcode position get stripped.
+codesep_strip_simple :: TestTree
+codesep_strip_simple = H.testCase "0xab at opcode position stripped" $
+ H.assertEqual "" (BS.pack [0x00, 0x51, 0x63, 0xac])
+ (strip_codeseparators (BS.pack [0x00, 0xab, 0xab, 0x51, 0x63, 0xac]))
+
+-- A direct push (opcode 0x02) of two 0xab bytes: data preserved.
+codesep_inside_push :: TestTree
+codesep_inside_push = H.testCase "0xab inside push data preserved" $
+ let s = BS.pack [0x02, 0xab, 0xab, 0x51] -- push 2 bytes, then OP_1
+ in H.assertEqual "" s (strip_codeseparators s)
+
+-- OP_PUSHDATA1 with 3 bytes of 0xab, followed by a lone 0xab opcode and
+-- OP_1. The data must be preserved; the trailing 0xab opcode stripped.
+codesep_inside_pushdata1 :: TestTree
+codesep_inside_pushdata1 =
+ H.testCase "OP_PUSHDATA1 data preserved, trailing 0xab stripped" $
+ let input = BS.pack [0x4c, 0x03, 0xab, 0xab, 0xab, 0xab, 0x51]
+ expected = BS.pack [0x4c, 0x03, 0xab, 0xab, 0xab, 0x51]
+ in H.assertEqual "" expected (strip_codeseparators input)
+
+-- | Arbitrary ByteString generator (QuickCheck has no built-in instance).
+genByteString :: Gen BS.ByteString
+genByteString = BS.pack <$> arbitrary
+
+prop_strip_codesep_idempotent :: TestTree
+prop_strip_codesep_idempotent =
+ QC.testProperty "strip_codeseparators is idempotent" $
+ forAll genByteString $ \s ->
+ strip_codeseparators (strip_codeseparators s)
+ === strip_codeseparators s
+
+prop_strip_codesep_no_0xab_unchanged :: TestTree
+prop_strip_codesep_no_0xab_unchanged =
+ QC.testProperty "strip_codeseparators is no-op without 0xab bytes" $
+ forAll (BS.pack . filter (/= 0xab) <$> arbitrary) $ \s ->
+ strip_codeseparators s === s
+