commit 17e6d5d6664c778f215a9e4e9906c3512f66c0de
parent 963d7eac442d5585b8cb18948c2cc146081b1189
Author: Jared Tobin <jared@jtobin.io>
Date: Sat, 10 Oct 2026 13:29:47 +0400
lib: follow spec framing for failure messages
A failure message is u16 failure_code || data || optional TLV stream;
there is no length field after the code. encodeFailureMessage wrote
one and decodeFailureMessage required it, so spec-encoded messages
(e.g. 2002) could not be parsed and attributed.
decodeFailureMessage now splits the data from the TLV stream using
the failure code's data layout, ignores trailing bytes that are not
a TLV stream, and returns everything after an unknown code as data.
Return packets were padded so that failure_len + pad_len was 220;
the spec requires at least 256. Pad to 256 as it recommends, and
document minErrorPacketSize as the bound on failure_len + pad_len.
Existing error tests are updated to use well-formed failure data.
Diffstat:
3 files changed, 209 insertions(+), 32 deletions(-)
diff --git a/lib/Lightning/Protocol/BOLT4/Codec.hs b/lib/Lightning/Protocol/BOLT4/Codec.hs
@@ -50,6 +50,7 @@ import Data.Bits (shiftL)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Lazy as BL
+import Data.Maybe (fromMaybe)
import Data.Word (Word16, Word32, Word64)
import Lightning.Protocol.BOLT4.Types
@@ -254,28 +255,81 @@ decodeShortChannelId !bs
-- Failure messages ---------------------------------------------------------
-- | Encode failure message.
+--
+-- Format: u16 failure_code || data || optional TLV stream. The data
+-- must already be laid out as the failure code requires.
encodeFailureMessage :: FailureMessage -> BS.ByteString
encodeFailureMessage (FailureMessage (FailureCode !code) !dat !tlvs) =
toStrict $
B.word16BE code <>
- B.word16BE (fromIntegral (BS.length dat)) <>
B.byteString dat <>
B.byteString (encodeTlvStream tlvs)
{-# INLINE encodeFailureMessage #-}
-- | Decode failure message.
+--
+-- For known failure codes the data is split off according to the
+-- code's layout, and any remaining bytes are parsed as a TLV stream.
+-- Remaining bytes that are not a valid TLV stream are ignored. For
+-- unknown failure codes all bytes after the code are returned as data.
+--
+-- Returns 'Nothing' if the input is shorter than the failure code or
+-- than the data its code requires.
decodeFailureMessage :: BS.ByteString -> Maybe FailureMessage
decodeFailureMessage !bs = do
- if BS.length bs < 4 then Nothing else do
- let !code = word16BE (BS.take 2 bs)
- !dlen = fromIntegral (word16BE (BS.take 2 (BS.drop 2 bs)))
- if BS.length bs < 4 + dlen then Nothing else do
- let !dat = BS.take dlen (BS.drop 4 bs)
- !tlvBytes = BS.drop (4 + dlen) bs
- tlvs <- if BS.null tlvBytes
- then Just []
- else decodeTlvStream tlvBytes
- Just (FailureMessage (FailureCode code) dat tlvs)
+ (hi, r0) <- BS.uncons bs
+ (lo, rest) <- BS.uncons r0
+ let !code = FailureCode (fromIntegral hi `shiftL` 8 + fromIntegral lo)
+ dlen <- failureDataLen code rest
+ let (!dat, !extra) = BS.splitAt dlen rest
+ !tlvs = if BS.null extra
+ then []
+ else fromMaybe [] (decodeTlvStream extra)
+ Just (FailureMessage code dat tlvs)
+
+-- | Length of the data that a failure code requires, given the bytes
+-- following the code.
+failureDataLen :: FailureCode -> BS.ByteString -> Maybe Int
+failureDataLen !code !bs = case code of
+ InvalidRealm -> fixed 0
+ TemporaryNodeFailure -> fixed 0
+ PermanentNodeFailure -> fixed 0
+ RequiredNodeFeatureMissing -> fixed 0
+ InvalidOnionVersion -> fixed 32
+ InvalidOnionHmac -> fixed 32
+ InvalidOnionKey -> fixed 32
+ TemporaryChannelFailure -> update 0
+ PermanentChannelFailure -> fixed 0
+ FailureCode 0x4009 -> fixed 0 -- req. channel feature
+ FailureCode 0x400A -> fixed 0 -- unknown_next_peer
+ AmountBelowMinimum -> update 8
+ FeeInsufficient -> update 8
+ IncorrectCltvExpiry -> update 4
+ ExpiryTooSoon -> update 0
+ IncorrectOrUnknownPaymentDetails -> fixed 12
+ FinalIncorrectCltvExpiry -> fixed 4
+ FinalIncorrectHtlcAmount -> fixed 8
+ ChannelDisabled -> update 2
+ ExpiryTooFar -> fixed 0
+ InvalidOnionPayload -> do
+ (_, r) <- decodeBigSize bs
+ fixed (BS.length bs - BS.length r + 2)
+ MppTimeout -> fixed 0
+ FailureCode 0xC018 -> fixed 32 -- invalid_onion_blinding
+ _ -> Just (BS.length bs)
+ where
+ fixed :: Int -> Maybe Int
+ fixed !n
+ | BS.length bs >= n = Just n
+ | otherwise = Nothing
+
+ -- n bytes of fields, then u16 len and a len-byte channel_update
+ update :: Int -> Maybe Int
+ update !n = do
+ (hi, r) <- BS.uncons (BS.drop n bs)
+ (lo, _) <- BS.uncons r
+ let !len = fromIntegral hi * 256 + fromIntegral lo :: Int
+ fixed (n + 2 + len)
-- Helper functions ---------------------------------------------------------
diff --git a/lib/Lightning/Protocol/BOLT4/Error.hs b/lib/Lightning/Protocol/BOLT4/Error.hs
@@ -52,7 +52,9 @@ data AttributionResult
-- ^ Could not attribute to any hop
deriving (Eq, Show)
--- | Minimum error packet size (256 bytes per spec).
+-- | Minimum of @failure_len + pad_len@ in a return packet (256 bytes
+-- per spec). A return packet is therefore at least 32 + 2 + 256 + 2
+-- = 292 bytes.
minErrorPacketSize :: Int
minErrorPacketSize = 256
{-# INLINE minErrorPacketSize #-}
@@ -119,7 +121,9 @@ unwrapError secrets (ErrorPacket !initialPacket) = go 0 initialPacket secrets
-- | Build the inner error message structure.
--
-- Format: HMAC (32) || len (2) || message || pad_len (2) || padding
--- Total must be >= 256 bytes.
+--
+-- The padding brings len + pad_len up to 256, as the spec recommends;
+-- longer messages are not padded.
buildErrorMessage
:: DerivedKey -- ^ um key
-> FailureMessage -- ^ failure to encode
@@ -127,9 +131,7 @@ buildErrorMessage
buildErrorMessage (DerivedKey !umKey) !failure =
let !encoded = encodeFailureMessage failure
!msgLen = BS.length encoded
- -- Total payload: len(2) + msg + pad_len(2) + padding = 256 - 32 = 224
- -- padding = 224 - 2 - msgLen - 2 = 220 - msgLen
- !padLen = max 0 (minErrorPacketSize - 32 - 2 - msgLen - 2)
+ !padLen = max 0 (minErrorPacketSize - msgLen)
!padding = BS.replicate padLen 0
-- Build: len || message || pad_len || padding
!payload = toStrict $
diff --git a/test/Main.hs b/test/Main.hs
@@ -7,6 +7,7 @@ import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Crypto.Curve.Secp256k1 as Secp256k1
import qualified Crypto.Hash.SHA256 as SHA256
+import Data.List (nub, sort)
import Data.Maybe (isJust)
import Data.Word (Word8, Word16, Word32)
import Lightning.Protocol.BOLT4.Blinding
@@ -744,6 +745,7 @@ errorTests = testGroup "error handling" [
, testMultiHopWrapping
, testErrorAttribution
, testFailureMessageParsing
+ , testFailureMessageFraming
]
-- Shared secrets for testing (deterministic)
@@ -759,18 +761,60 @@ testSecret3 = SharedSecret (BS.replicate 32 0x33)
testSecret4 :: SharedSecret
testSecret4 = SharedSecret (BS.replicate 32 0x44)
--- Simple failure message for testing
+-- Simple failure message for testing (htlc_msat 1000, height 800000)
testFailure :: FailureMessage
-testFailure =
- FailureMessage IncorrectOrUnknownPaymentDetails BS.empty []
+testFailure = FailureMessage IncorrectOrUnknownPaymentDetails
+ (BS.pack [0, 0, 0, 0, 0, 0, 0x03, 0xe8, 0, 0x0c, 0x35, 0x00]) []
+
+-- | Minimal well-formed data for a failure code.
+failureData :: FailureCode -> BS.ByteString
+failureData c
+ | c `elem` [InvalidOnionVersion, InvalidOnionHmac, InvalidOnionKey] =
+ BS.replicate 32 0xab
+ | c `elem` [TemporaryChannelFailure, ExpiryTooSoon] = BS.pack [0, 0]
+ | c `elem` [AmountBelowMinimum, FeeInsufficient] =
+ BS.replicate 8 0x01 <> BS.pack [0, 0]
+ | c == IncorrectCltvExpiry = BS.replicate 4 0x01 <> BS.pack [0, 0]
+ | c == ChannelDisabled = BS.pack [0, 1, 0, 0]
+ | c == IncorrectOrUnknownPaymentDetails = BS.replicate 12 0x01
+ | c == FinalIncorrectCltvExpiry = BS.replicate 4 0x01
+ | c == FinalIncorrectHtlcAmount = BS.replicate 8 0x01
+ | c == InvalidOnionPayload = BS.pack [0x02, 0x00, 0x05]
+ | otherwise = BS.empty
+
+-- | Read a big-endian u16 at an offset, failing the test if short.
+u16At :: Int -> BS.ByteString -> IO Int
+u16At off bs = case BS.unpack (BS.take 2 (BS.drop off bs)) of
+ [hi, lo] -> pure (fromIntegral hi * 256 + fromIntegral lo)
+ _ -> assertFailure "u16At: input too short"
+
+-- | Strip the failing node's obfuscation from a return packet and
+-- return (failure_len, pad_len, packet length).
+returnPacketLengths :: SharedSecret -> ErrorPacket -> IO (Int, Int, Int)
+returnPacketLengths ss pkt = do
+ let ErrorPacket raw = wrapError ss pkt
+ failureLen <- u16At 32 raw
+ padLen <- u16At (34 + failureLen) raw
+ pure (failureLen, padLen, BS.length raw)
testErrorConstruction :: TestTree
-testErrorConstruction =
- testCase "error packet construction" $ do
- let errPacket = constructError testSecret1 testFailure
- ErrorPacket bs = errPacket
- assertBool "error packet >= 256 bytes"
- (BS.length bs >= minErrorPacketSize)
+testErrorConstruction = testGroup "error packet construction" [
+ testCase "short message is padded to 256" $ do
+ let failure = FailureMessage TemporaryNodeFailure BS.empty []
+ errPacket = constructError testSecret1 failure
+ (fl, pl, size) <- returnPacketLengths testSecret1 errPacket
+ fl @?= 2
+ fl + pl @?= minErrorPacketSize
+ size @?= 32 + 2 + fl + 2 + pl
+ , testCase "long message is not truncated or padded" $ do
+ let failure = FailureMessage TemporaryNodeFailure BS.empty
+ [TlvRecord 1 (BS.replicate 300 0x00)]
+ errPacket = constructError testSecret1 failure
+ (fl, pl, size) <- returnPacketLengths testSecret1 errPacket
+ fl @?= 2 + 1 + 3 + 300
+ pl @?= 0
+ size @?= 32 + 2 + fl + 2
+ ]
testErrorRoundtrip :: TestTree
testErrorRoundtrip =
@@ -858,7 +902,7 @@ testErrorAttribution = testGroup "error attribution" [
, (InvalidOnionHmac, testSecret3)
]
mapM_ (\(code, secret) -> do
- let failure = FailureMessage code BS.empty []
+ let failure = FailureMessage code (failureData code) []
err = constructError secret failure
result = unwrapError [secret] err
case result of
@@ -872,7 +916,7 @@ testFailureMessageParsing :: TestTree
testFailureMessageParsing =
testGroup "failure message parsing" [
testCase "code with data" $ do
- let failData = BS.replicate 10 0xAB
+ let failData = BS.pack [0, 0, 0, 0, 0, 0, 0x03, 0xe8, 0, 0]
failure =
FailureMessage AmountBelowMinimum failData []
err = constructError testSecret1 failure
@@ -893,7 +937,7 @@ testFailureMessageParsing =
, IncorrectOrUnknownPaymentDetails
]
mapM_ (\code -> do
- let failure = FailureMessage code BS.empty []
+ let failure = FailureMessage code (failureData code) []
err = constructError testSecret1 failure
result = unwrapError [testSecret1] err
case result of
@@ -903,6 +947,41 @@ testFailureMessageParsing =
) codes
]
+testFailureMessageFraming :: TestTree
+testFailureMessageFraming = testGroup "failure message framing" [
+ testCase "no length after the failure code" $ do
+ encodeFailureMessage
+ (FailureMessage TemporaryNodeFailure BS.empty [])
+ @?= BS.pack [0x20, 0x02]
+ encodeFailureMessage testFailure
+ @?= BS.pack [ 0x40, 0x0f, 0, 0, 0, 0, 0, 0, 0x03, 0xe8
+ , 0, 0x0c, 0x35, 0x00 ]
+ , testCase "decodes spec-framed messages" $ do
+ decodeFailureMessage (BS.pack [0x20, 0x02])
+ @?= Just (FailureMessage TemporaryNodeFailure BS.empty [])
+ decodeFailureMessage (encodeFailureMessage testFailure)
+ @?= Just testFailure
+ , testCase "splits data from the TLV stream by code" $ do
+ let fm = FailureMessage FinalIncorrectHtlcAmount
+ (BS.replicate 8 0x07) [TlvRecord 3 (BS.pack [1, 2])]
+ decodeFailureMessage (encodeFailureMessage fm) @?= Just fm
+ , testCase "channel_update length is honoured" $ do
+ let fm = FailureMessage TemporaryChannelFailure
+ (BS.pack [0, 3, 0xaa, 0xbb, 0xcc]) []
+ decodeFailureMessage (encodeFailureMessage fm) @?= Just fm
+ , testCase "ignores extra bytes" $
+ decodeFailureMessage (BS.pack [0x20, 0x02, 0xff])
+ @?= Just (FailureMessage TemporaryNodeFailure BS.empty [])
+ , testCase "unknown code keeps the remainder as data" $
+ decodeFailureMessage (BS.pack [0x20, 0x63, 0x01, 0x02])
+ @?= Just (FailureMessage (FailureCode 0x2063)
+ (BS.pack [0x01, 0x02]) [])
+ , testCase "rejects truncated data" $ do
+ decodeFailureMessage (BS.pack [0x40, 0x0f, 0, 0, 0]) @?= Nothing
+ decodeFailureMessage (BS.pack [0x10, 0x07, 0, 2, 0xaa]) @?= Nothing
+ decodeFailureMessage (BS.pack [0x20]) @?= Nothing
+ ]
+
-- Blinding tests -----------------------------------------------------------
-- Test data setup
@@ -1432,17 +1511,58 @@ genFailureMessage = do
[ InvalidRealm
, TemporaryNodeFailure
, PermanentNodeFailure
+ , RequiredNodeFeatureMissing
+ , InvalidOnionVersion
, InvalidOnionHmac
+ , InvalidOnionKey
, TemporaryChannelFailure
- , IncorrectOrUnknownPaymentDetails
+ , PermanentChannelFailure
, AmountBelowMinimum
, FeeInsufficient
+ , IncorrectCltvExpiry
, ExpiryTooSoon
+ , IncorrectOrUnknownPaymentDetails
+ , FinalIncorrectCltvExpiry
+ , FinalIncorrectHtlcAmount
+ , ChannelDisabled
+ , ExpiryTooFar
+ , InvalidOnionPayload
, MppTimeout
]
- dlen <- choose (0, 100 :: Int)
- dat <- BS.pack <$> vectorOf dlen arbitrary
- pure (FailureMessage code dat [])
+ dat <- genFailureData code
+ ntlv <- choose (0, 3 :: Int)
+ types <- (nub . sort) <$> vectorOf ntlv arbitrary
+ tlvs <- mapM (\t -> do
+ len <- choose (0, 20 :: Int)
+ TlvRecord t . BS.pack <$> vectorOf len arbitrary) types
+ pure (FailureMessage code dat tlvs)
+
+-- | Random well-formed data for a failure code.
+genFailureData :: FailureCode -> Gen BS.ByteString
+genFailureData c
+ | c `elem` [InvalidOnionVersion, InvalidOnionHmac, InvalidOnionKey] =
+ bytes 32
+ | c `elem` [TemporaryChannelFailure, ExpiryTooSoon] = update 0
+ | c `elem` [AmountBelowMinimum, FeeInsufficient] = update 8
+ | c == IncorrectCltvExpiry = update 4
+ | c == ChannelDisabled = update 2
+ | c == IncorrectOrUnknownPaymentDetails = bytes 12
+ | c == FinalIncorrectCltvExpiry = bytes 4
+ | c == FinalIncorrectHtlcAmount = bytes 8
+ | c == InvalidOnionPayload = do
+ typ <- arbitrary
+ off <- bytes 2
+ pure (encodeBigSize typ <> off)
+ | otherwise = pure BS.empty
+ where
+ bytes n = BS.pack <$> vectorOf n arbitrary
+ update n = do
+ fields <- bytes n
+ len <- choose (0, 300 :: Int)
+ cu <- bytes len
+ pure $ fields
+ <> BS.pack [fromIntegral (len `div` 256), fromIntegral len]
+ <> cu
-- Spec vectors: onion-test.json --------------------------------------------
@@ -1639,3 +1759,4 @@ genRoutePayload n = do
ti <- choose (0, 0xFFFFFF)
oi <- arbitrary
pure (shortChannelId bh ti oi)
+