bolt7

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

commit 9dc08f2708ef257b265f3bdd7233bc7c8493cfd5
parent 920e3898f367b354ba5fad0ca4a04cc7b721d022
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 13:24:58 +0400

lib: check hash input lengths; fix checksum doc offset

The hash and checksum functions hashed whatever they were given, so
short or mis-framed input produced a garbage digest with no error
(channelUpdateChecksum "short" returned 0).

They now return Maybe and give Nothing when the input cannot hold
the message: channel_update needs its 136 fixed bytes, and the two
announcements must hold their declared feature (and, for
node_announcement, address) lengths. The module doc states that the
input is the received payload after the 2-byte message type.

The channelUpdateChecksum doc put the timestamp at bytes 96-99; it
is at 104-107, as the layout comment and the code already said.

Signature changes:

  channelAnnouncementHash :: ByteString -> Maybe ByteString
  nodeAnnouncementHash    :: ByteString -> Maybe ByteString
  channelUpdateHash       :: ByteString -> Maybe ByteString
  channelUpdateChecksum   :: ByteString -> Maybe Word32

Diffstat:
Mlib/Lightning/Protocol/BOLT7.hs | 9++++++---
Mlib/Lightning/Protocol/BOLT7/Hash.hs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtest/Main.hs | 6+++---
3 files changed, 81 insertions(+), 38 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT7.hs b/lib/Lightning/Protocol/BOLT7.hs @@ -55,12 +55,15 @@ -- -- == Signature verification -- --- This library provides hash computation for signature verification: +-- This library provides hash computation for signature verification. +-- The hash functions take the received payload (the bytes after the +-- 2-byte message type): -- -- @ -- -- Compute the hash that should be signed --- let sigHash = channelAnnouncementHash encodedMessage --- -- Verify signatures using ppad-secp256k1 (not included) +-- case channelAnnouncementHash payload of +-- Nothing -> rejectMessage -- too short to be a channel_announcement +-- Just sigHash -> verifyWith sigHash -- e.g. using ppad-secp256k1 -- @ -- -- = Protocol overview diff --git a/lib/Lightning/Protocol/BOLT7/Hash.hs b/lib/Lightning/Protocol/BOLT7/Hash.hs @@ -13,6 +13,12 @@ -- 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). +-- +-- Each function takes the message payload as received: the bytes after +-- the 2-byte message type. The signatures cover every byte of that +-- payload, so hash the received bytes rather than a re-encoding of a +-- decoded message. Each function returns 'Nothing' if the input is too +-- short to hold the message. module Lightning.Protocol.BOLT7.Hash ( -- * Signature hashes @@ -28,57 +34,89 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as BS import Data.Word (Word32) import qualified Crypto.Hash.SHA256 as SHA256 +import qualified Lightning.Protocol.BOLT1.Prim as Prim import Lightning.Protocol.BOLT7.CRC32C (crc32c) -import Lightning.Protocol.BOLT7.Types (signatureLen, chainHashLen) +import Lightning.Protocol.BOLT7.Types + ( signatureLen, chainHashLen, shortChannelIdLen, pointLen, nodeIdLen + , rgbColorLen, aliasLen ) -- | Double SHA-256 hash (used for Lightning message signing). doubleSha256 :: ByteString -> ByteString doubleSha256 = SHA256.hash . SHA256.hash {-# INLINE doubleSha256 #-} +-- | Read the big-endian u16 at an offset, if present. +u16At :: Int -> ByteString -> Maybe Int +u16At off bs = fmap (fromIntegral . fst) (Prim.decodeU16 (BS.drop off bs)) +{-# INLINE u16At #-} + +-- | Length of a channel_update without extension bytes (136). +channelUpdateLen :: Int +channelUpdateLen = signatureLen + chainHashLen + shortChannelIdLen + 32 +{-# INLINE channelUpdateLen #-} + -- | 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 - payload = BS.drop offset msg - in doubleSha256 payload +-- Returns the double-SHA256 hash (32 bytes), or 'Nothing' if the input +-- cannot hold the fields of a channel_announcement with its declared +-- feature length. +channelAnnouncementHash :: ByteString -> Maybe ByteString +channelAnnouncementHash !msg = do + let !offset = 4 * signatureLen + flen <- u16At offset msg + -- features, chain_hash, short_channel_id, node_ids, bitcoin_keys + let !minLen = offset + 2 + flen + chainHashLen + shortChannelIdLen + + 2 * nodeIdLen + 2 * pointLen + if BS.length msg < minLen + then Nothing + else Just (doubleSha256 (BS.drop offset msg)) {-# 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 doubleSha256 payload +-- Returns the double-SHA256 hash (32 bytes), or 'Nothing' if the input +-- cannot hold the fields of a node_announcement with its declared +-- feature and address lengths. +nodeAnnouncementHash :: ByteString -> Maybe ByteString +nodeAnnouncementHash !msg = do + flen <- u16At signatureLen msg + -- features, timestamp, node_id, rgb_color, alias + let !addrOff = signatureLen + 2 + flen + 4 + nodeIdLen + rgbColorLen + + aliasLen + addrlen <- u16At addrOff msg + if BS.length msg < addrOff + 2 + addrlen + then Nothing + else Just (doubleSha256 (BS.drop signatureLen msg)) {-# 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 doubleSha256 payload +-- Returns the double-SHA256 hash (32 bytes), or 'Nothing' if the input +-- is shorter than a channel_update (136 bytes). +channelUpdateHash :: ByteString -> Maybe ByteString +channelUpdateHash !msg + | BS.length msg < channelUpdateLen = Nothing + | otherwise = Just (doubleSha256 (BS.drop signatureLen msg)) {-# 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). +-- signature field (bytes 0-63) and timestamp field (bytes 104-107). -- -- The checksum is used in the checksums_tlv of reply_channel_range. -- +-- Returns 'Nothing' if the input is shorter than a channel_update +-- (136 bytes). +-- -- Message layout after signature: -- - chain_hash: 32 bytes (offset 64-95) -- - short_channel_id: 8 bytes (offset 96-103) @@ -89,18 +127,20 @@ channelUpdateHash !msg = -- - 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 +-- - htlc_maximum_msat: 8 bytes (offset 128-135) +channelUpdateChecksum :: ByteString -> Maybe Word32 +channelUpdateChecksum !msg + | BS.length msg < channelUpdateLen = Nothing + | otherwise = Just (crc32c checksumData) + where + -- 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] {-# INLINE channelUpdateChecksum #-} diff --git a/test/Main.hs b/test/Main.hs @@ -602,7 +602,7 @@ hash_tests = testGroup "Hash Functions" [ Left e -> assertFailure $ "encode failed: " ++ show e Right msg -> do let hashVal = channelAnnouncementHash msg - BS.length hashVal @?= 32 + fmap BS.length hashVal @?= Just 32 , testCase "nodeAnnouncementHash produces 32 bytes" $ do case encodeNodeAnnouncement NodeAnnouncement { nodeAnnSignature = testSignature @@ -616,7 +616,7 @@ hash_tests = testGroup "Hash Functions" [ Left e -> assertFailure $ "encode failed: " ++ show e Right msg -> do let hashVal = nodeAnnouncementHash msg - BS.length hashVal @?= 32 + fmap BS.length hashVal @?= Just 32 , testCase "channelUpdateHash produces 32 bytes" $ do let msg = encodeChannelUpdate ChannelUpdate { chanUpdateSignature = testSignature @@ -633,7 +633,7 @@ hash_tests = testGroup "Hash Functions" [ , chanUpdateHtlcMaxMsat = HtlcMaximumMsat 1000000000 } hashVal = channelUpdateHash msg - BS.length hashVal @?= 32 + fmap BS.length hashVal @?= Just 32 ] , testGroup "Checksum" [ testCase "channelUpdateChecksum produces consistent result" $ do