bolt7

Routing gossip protocol, per BOLT #7.
git clone git://git.ppad.tech/bolt7.git
Log | Files | Refs | README | LICENSE

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:
Mflake.lock | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mflake.nix | 17++++++++++++++++-
Mlib/Lightning/Protocol/BOLT7.hs | 5+++++
Alib/Lightning/Protocol/BOLT7/CRC32C.hs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Alib/Lightning/Protocol/BOLT7/Hash.hs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mppad-bolt7.cabal | 3+++
Mtest/Main.hs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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