commit 4b9ae7011c609655b2d5c14038617dc56b358d44
parent 0c4cf4e90876f7fbcc3c1ed10e12e0a24067f1fe
Author: Jared Tobin <jared@jtobin.io>
Date: Sun, 25 Jan 2026 15:23:28 +0400
Phase 4: Add signature hash and checksum computation
Add CRC32C module:
- Pure Haskell implementation of CRC-32C (Castagnoli polynomial)
- Used for channel_update checksums in reply_channel_range
Add Hash module:
- channelAnnouncementHash: double-SHA256 for channel_announcement
- nodeAnnouncementHash: double-SHA256 for node_announcement
- channelUpdateHash: double-SHA256 for channel_update
- channelUpdateChecksum: CRC-32C excluding signature and timestamp
Add ppad-sha256 dependency for hash computation.
Add tests:
- CRC32C known test vectors
- Hash function output length verification
- Checksum consistency and timestamp invariance
All 43 tests pass.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
7 files changed, 353 insertions(+), 2 deletions(-)
diff --git a/flake.lock b/flake.lock
@@ -68,6 +68,40 @@
"url": "git://git.ppad.tech/base16.git"
}
},
+ "ppad-base16_2": {
+ "inputs": {
+ "flake-utils": [
+ "ppad-sha256",
+ "ppad-base16",
+ "ppad-nixpkgs",
+ "flake-utils"
+ ],
+ "nixpkgs": [
+ "ppad-sha256",
+ "ppad-base16",
+ "ppad-nixpkgs",
+ "nixpkgs"
+ ],
+ "ppad-nixpkgs": [
+ "ppad-sha256",
+ "ppad-nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1766934151,
+ "narHash": "sha256-BUFpuLfrGXE2xi3Wa9TYCEhhRhFp175Ghxnr0JRbG2I=",
+ "ref": "master",
+ "rev": "58dfb7922401a60d5de76825fcd5f6ecbcd7afe0",
+ "revCount": 26,
+ "type": "git",
+ "url": "git://git.ppad.tech/base16.git"
+ },
+ "original": {
+ "ref": "master",
+ "type": "git",
+ "url": "git://git.ppad.tech/base16.git"
+ }
+ },
"ppad-bolt1": {
"inputs": {
"flake-utils": [
@@ -116,6 +150,38 @@
"url": "git://git.ppad.tech/nixpkgs.git"
}
},
+ "ppad-sha256": {
+ "inputs": {
+ "flake-utils": [
+ "ppad-sha256",
+ "ppad-nixpkgs",
+ "flake-utils"
+ ],
+ "nixpkgs": [
+ "ppad-sha256",
+ "ppad-nixpkgs",
+ "nixpkgs"
+ ],
+ "ppad-base16": "ppad-base16_2",
+ "ppad-nixpkgs": [
+ "ppad-nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768121850,
+ "narHash": "sha256-RxgAI88nZi4o4xYj1v+GC0X5E9adae12dDSmv/GFu2Y=",
+ "ref": "master",
+ "rev": "916595b21319ca270ce8beb9c742bf7e632cccc9",
+ "revCount": 118,
+ "type": "git",
+ "url": "git://git.ppad.tech/sha256.git"
+ },
+ "original": {
+ "ref": "master",
+ "type": "git",
+ "url": "git://git.ppad.tech/sha256.git"
+ }
+ },
"root": {
"inputs": {
"flake-utils": [
@@ -127,7 +193,8 @@
"nixpkgs"
],
"ppad-bolt1": "ppad-bolt1",
- "ppad-nixpkgs": "ppad-nixpkgs"
+ "ppad-nixpkgs": "ppad-nixpkgs",
+ "ppad-sha256": "ppad-sha256"
}
},
"systems": {
diff --git a/flake.nix b/flake.nix
@@ -7,6 +7,12 @@
path = "/Users/jtobin/src/ppad/bolt1";
inputs.ppad-nixpkgs.follows = "ppad-nixpkgs";
};
+ ppad-sha256 = {
+ type = "git";
+ url = "git://git.ppad.tech/sha256.git";
+ ref = "master";
+ inputs.ppad-nixpkgs.follows = "ppad-nixpkgs";
+ };
ppad-nixpkgs = {
type = "git";
url = "git://git.ppad.tech/nixpkgs.git";
@@ -16,7 +22,8 @@
nixpkgs.follows = "ppad-nixpkgs/nixpkgs";
};
- outputs = { self, nixpkgs, flake-utils, ppad-nixpkgs, ppad-bolt1 }:
+ outputs = { self, nixpkgs, flake-utils, ppad-nixpkgs
+ , ppad-bolt1, ppad-sha256 }:
flake-utils.lib.eachDefaultSystem (system:
let
lib = "ppad-bolt7";
@@ -32,10 +39,18 @@
(hlib.enableCabalFlag bolt1 "llvm")
[ llvm clang ];
+ sha256 = ppad-sha256.packages.${system}.default;
+ sha256-llvm =
+ hlib.addBuildTools
+ (hlib.enableCabalFlag sha256 "llvm")
+ [ llvm clang ];
+
hpkgs = pkgs.haskell.packages.ghc910.extend (new: old: {
ppad-bolt1 = bolt1-llvm;
+ ppad-sha256 = sha256-llvm;
${lib} = new.callCabal2nix lib ./. {
ppad-bolt1 = new.ppad-bolt1;
+ ppad-sha256 = new.ppad-sha256;
};
});
diff --git a/lib/Lightning/Protocol/BOLT7.hs b/lib/Lightning/Protocol/BOLT7.hs
@@ -22,6 +22,10 @@ module Lightning.Protocol.BOLT7 (
-- | Re-exported from "Lightning.Protocol.BOLT7.Codec".
, module Lightning.Protocol.BOLT7.Codec
+ -- * Hash functions
+ -- | Re-exported from "Lightning.Protocol.BOLT7.Hash".
+ , module Lightning.Protocol.BOLT7.Hash
+
-- $messagetypes
-- ** Channel announcement
@@ -38,6 +42,7 @@ module Lightning.Protocol.BOLT7 (
) where
import Lightning.Protocol.BOLT7.Codec
+import Lightning.Protocol.BOLT7.Hash
import Lightning.Protocol.BOLT7.Messages
import Lightning.Protocol.BOLT7.Types
diff --git a/lib/Lightning/Protocol/BOLT7/CRC32C.hs b/lib/Lightning/Protocol/BOLT7/CRC32C.hs
@@ -0,0 +1,49 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module: Lightning.Protocol.BOLT7.CRC32C
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- CRC-32C (Castagnoli) implementation for BOLT #7 checksums.
+--
+-- This is an internal helper module implementing CRC-32C as specified
+-- in RFC 3720. CRC-32C uses the Castagnoli polynomial 0x1EDC6F41.
+
+module Lightning.Protocol.BOLT7.CRC32C (
+ crc32c
+ ) where
+
+import Data.Bits (shiftR, xor, (.&.))
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.Word (Word8, Word32)
+
+-- | CRC-32C polynomial (Castagnoli): 0x1EDC6F41 reflected = 0x82F63B78
+crc32cPoly :: Word32
+crc32cPoly = 0x82F63B78
+{-# INLINE crc32cPoly #-}
+
+-- | Compute CRC-32C of a bytestring.
+--
+-- >>> crc32c "123456789"
+-- 0xe3069283
+crc32c :: ByteString -> Word32
+crc32c = xor 0xFFFFFFFF . BS.foldl' updateByte 0xFFFFFFFF
+{-# INLINE crc32c #-}
+
+-- | Update CRC with a single byte.
+updateByte :: Word32 -> Word8 -> Word32
+updateByte !crc !byte =
+ let crc' = crc `xor` fromIntegral byte
+ in go 8 crc'
+ where
+ go :: Int -> Word32 -> Word32
+ go 0 !c = c
+ go !n !c =
+ let c' = if c .&. 1 /= 0
+ then (c `shiftR` 1) `xor` crc32cPoly
+ else c `shiftR` 1
+ in go (n - 1) c'
+{-# INLINE updateByte #-}
diff --git a/lib/Lightning/Protocol/BOLT7/Hash.hs b/lib/Lightning/Protocol/BOLT7/Hash.hs
@@ -0,0 +1,99 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module: Lightning.Protocol.BOLT7.Hash
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Signature hash computation for BOLT #7 messages.
+--
+-- These functions compute the double-SHA256 hash that is signed in each
+-- message type. The hash covers the message content excluding the
+-- signature field(s).
+
+module Lightning.Protocol.BOLT7.Hash (
+ -- * Signature hashes
+ channelAnnouncementHash
+ , nodeAnnouncementHash
+ , channelUpdateHash
+
+ -- * Checksums
+ , channelUpdateChecksum
+ ) where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.Word (Word32)
+import qualified Crypto.Hash.SHA256 as SHA256
+import Lightning.Protocol.BOLT7.CRC32C (crc32c)
+import Lightning.Protocol.BOLT7.Types (signatureLen, chainHashLen)
+
+-- | Compute signature hash for channel_announcement.
+--
+-- The hash covers the message starting at byte offset 256, which is after
+-- the four 64-byte signatures (node_sig_1, node_sig_2, bitcoin_sig_1,
+-- bitcoin_sig_2).
+--
+-- Returns the double-SHA256 hash (32 bytes).
+channelAnnouncementHash :: ByteString -> ByteString
+channelAnnouncementHash !msg =
+ let offset = 4 * signatureLen -- 4 signatures * 64 bytes = 256
+ payload = BS.drop offset msg
+ in SHA256.hash (SHA256.hash payload)
+{-# INLINE channelAnnouncementHash #-}
+
+-- | Compute signature hash for node_announcement.
+--
+-- The hash covers the message starting after the signature field (64 bytes).
+--
+-- Returns the double-SHA256 hash (32 bytes).
+nodeAnnouncementHash :: ByteString -> ByteString
+nodeAnnouncementHash !msg =
+ let payload = BS.drop signatureLen msg
+ in SHA256.hash (SHA256.hash payload)
+{-# INLINE nodeAnnouncementHash #-}
+
+-- | Compute signature hash for channel_update.
+--
+-- The hash covers the message starting after the signature field (64 bytes).
+--
+-- Returns the double-SHA256 hash (32 bytes).
+channelUpdateHash :: ByteString -> ByteString
+channelUpdateHash !msg =
+ let payload = BS.drop signatureLen msg
+ in SHA256.hash (SHA256.hash payload)
+{-# INLINE channelUpdateHash #-}
+
+-- | Compute checksum for channel_update.
+--
+-- This is the CRC-32C of the channel_update message excluding the
+-- signature field (bytes 0-63) and timestamp field (bytes 96-99).
+--
+-- The checksum is used in the checksums_tlv of reply_channel_range.
+--
+-- Message layout after signature:
+-- - chain_hash: 32 bytes (offset 64-95)
+-- - short_channel_id: 8 bytes (offset 96-103)
+-- - timestamp: 4 bytes (offset 104-107) -- EXCLUDED
+-- - message_flags: 1 byte (offset 108)
+-- - channel_flags: 1 byte (offset 109)
+-- - cltv_expiry_delta: 2 bytes (offset 110-111)
+-- - htlc_minimum_msat: 8 bytes (offset 112-119)
+-- - fee_base_msat: 4 bytes (offset 120-123)
+-- - fee_proportional_millionths: 4 bytes (offset 124-127)
+-- - htlc_maximum_msat: 8 bytes (offset 128-135, if present)
+channelUpdateChecksum :: ByteString -> Word32
+channelUpdateChecksum !msg =
+ let -- Offset 64: chain_hash (32 bytes)
+ chainHash = BS.take chainHashLen (BS.drop signatureLen msg)
+ -- Offset 96: short_channel_id (8 bytes)
+ scid = BS.take 8 (BS.drop (signatureLen + chainHashLen) msg)
+ -- Skip timestamp (4 bytes at offset 104)
+ -- Offset 108 to end: rest of message
+ restOffset = signatureLen + chainHashLen + 8 + 4 -- 64 + 32 + 8 + 4 = 108
+ rest = BS.drop restOffset msg
+ -- Concatenate: chain_hash + scid + (message without sig and timestamp)
+ checksumData = BS.concat [chainHash, scid, rest]
+ in crc32c checksumData
+{-# INLINE channelUpdateChecksum #-}
diff --git a/ppad-bolt7.cabal b/ppad-bolt7.cabal
@@ -26,6 +26,8 @@ library
exposed-modules:
Lightning.Protocol.BOLT7
Lightning.Protocol.BOLT7.Codec
+ Lightning.Protocol.BOLT7.CRC32C
+ Lightning.Protocol.BOLT7.Hash
Lightning.Protocol.BOLT7.Messages
Lightning.Protocol.BOLT7.Types
build-depends:
@@ -33,6 +35,7 @@ library
, bytestring >= 0.9 && < 0.13
, deepseq >= 1.4 && < 1.6
, ppad-bolt1
+ , ppad-sha256 >= 0.3 && < 0.4
test-suite bolt7-tests
type: exitcode-stdio-1.0
diff --git a/test/Main.hs b/test/Main.hs
@@ -7,6 +7,7 @@ import Data.Maybe (fromJust)
import Data.Word (Word16, Word32)
import Lightning.Protocol.BOLT1 (TlvStream, unsafeTlvStream)
import Lightning.Protocol.BOLT7
+import Lightning.Protocol.BOLT7.CRC32C (crc32c)
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck
@@ -20,6 +21,7 @@ main = defaultMain $ testGroup "ppad-bolt7" [
, announcement_signatures_tests
, query_tests
, scid_list_tests
+ , hash_tests
, error_tests
, property_tests
]
@@ -367,6 +369,117 @@ scid_list_tests = testGroup "SCID List Encoding" [
Right _ -> assertFailure "should reject encoding type 1"
]
+-- Hash Tests -----------------------------------------------------------------
+
+hash_tests :: TestTree
+hash_tests = testGroup "Hash Functions" [
+ testGroup "CRC32C" [
+ testCase "known test vector '123456789'" $ do
+ -- Standard CRC-32C test vector
+ crc32c "123456789" @?= 0xe3069283
+ , testCase "empty string" $ do
+ crc32c "" @?= 0x00000000
+ ]
+ , testGroup "Signature Hashes" [
+ testCase "channelAnnouncementHash produces 32 bytes" $ do
+ -- Create a minimal valid encoded message
+ let msg = encodeChannelAnnouncement ChannelAnnouncement
+ { channelAnnNodeSig1 = testSignature
+ , channelAnnNodeSig2 = testSignature
+ , channelAnnBitcoinSig1 = testSignature
+ , channelAnnBitcoinSig2 = testSignature
+ , channelAnnFeatures = emptyFeatures
+ , channelAnnChainHash = testChainHash
+ , channelAnnShortChanId = testShortChannelId
+ , channelAnnNodeId1 = testNodeId
+ , channelAnnNodeId2 = testNodeId2
+ , channelAnnBitcoinKey1 = testPoint
+ , channelAnnBitcoinKey2 = testPoint
+ }
+ hashVal = channelAnnouncementHash msg
+ BS.length hashVal @?= 32
+ , testCase "nodeAnnouncementHash produces 32 bytes" $ do
+ case encodeNodeAnnouncement NodeAnnouncement
+ { nodeAnnSignature = testSignature
+ , nodeAnnFeatures = emptyFeatures
+ , nodeAnnTimestamp = 1234567890
+ , nodeAnnNodeId = testNodeId
+ , nodeAnnRgbColor = testRgbColor
+ , nodeAnnAlias = testAlias
+ , nodeAnnAddresses = []
+ } of
+ Left e -> assertFailure $ "encode failed: " ++ show e
+ Right msg -> do
+ let hashVal = nodeAnnouncementHash msg
+ BS.length hashVal @?= 32
+ , testCase "channelUpdateHash produces 32 bytes" $ do
+ let msg = encodeChannelUpdate ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 1234567890
+ , chanUpdateMsgFlags = 0x00
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Nothing
+ }
+ hashVal = channelUpdateHash msg
+ BS.length hashVal @?= 32
+ ]
+ , testGroup "Checksum" [
+ testCase "channelUpdateChecksum produces consistent result" $ do
+ -- The checksum should be deterministic
+ let msg = encodeChannelUpdate ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 1234567890
+ , chanUpdateMsgFlags = 0x00
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Nothing
+ }
+ cs1 = channelUpdateChecksum msg
+ cs2 = channelUpdateChecksum msg
+ cs1 @?= cs2
+ , testCase "different timestamps produce same checksum" $ do
+ -- Checksum excludes timestamp field
+ let msg1 = encodeChannelUpdate ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 1000000000
+ , chanUpdateMsgFlags = 0x00
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Nothing
+ }
+ msg2 = encodeChannelUpdate ChannelUpdate
+ { chanUpdateSignature = testSignature
+ , chanUpdateChainHash = testChainHash
+ , chanUpdateShortChanId = testShortChannelId
+ , chanUpdateTimestamp = 2000000000
+ , chanUpdateMsgFlags = 0x00
+ , chanUpdateChanFlags = 0x00
+ , chanUpdateCltvExpDelta = 144
+ , chanUpdateHtlcMinMsat = 1000
+ , chanUpdateFeeBaseMsat = 1000
+ , chanUpdateFeeProportional = 100
+ , chanUpdateHtlcMaxMsat = Nothing
+ }
+ channelUpdateChecksum msg1 @?= channelUpdateChecksum msg2
+ ]
+ ]
+
-- Error Tests -----------------------------------------------------------------
error_tests :: TestTree