bolt4

Onion routing protocol, per BOLT #4.
git clone git://git.ppad.tech/bolt4.git
Log | Files | Refs | README | LICENSE

commit cd8aa074cac8d4144a1e30868fe639de29d8ae35
parent 6f3327fe2856fded5511e8f8db21a44361d4c8c5
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 25 Jan 2026 15:39:03 +0400

ppad-bolt4: implement packet construction (IMPL3)

Add Construct module for BOLT4 onion packet construction:
- Hop type for route specification
- construct function for creating onion packets
- Shared secret computation with ephemeral key blinding
- Filler generation for constant-size packets
- Payload wrapping with ChaCha20 obfuscation

Includes test vectors from BOLT4 specification.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Alib/Lightning/Protocol/BOLT4/Construct.hs | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mppad-bolt4.cabal | 1+
Mtest/Main.hs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 365 insertions(+), 0 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT4/Construct.hs b/lib/Lightning/Protocol/BOLT4/Construct.hs @@ -0,0 +1,212 @@ +{-# OPTIONS_HADDOCK prune #-} +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- Module: Lightning.Protocol.BOLT4.Construct +-- Copyright: (c) 2025 Jared Tobin +-- License: MIT +-- Maintainer: Jared Tobin <jared@ppad.tech> +-- +-- Onion packet construction for BOLT4. + +module Lightning.Protocol.BOLT4.Construct ( + -- * Types + Hop(..) + , Error(..) + + -- * Packet construction + , construct + ) where + +import Data.Bits (xor) +import qualified Crypto.Curve.Secp256k1 as Secp256k1 +import qualified Data.ByteString as BS +import Lightning.Protocol.BOLT4.Codec +import Lightning.Protocol.BOLT4.Prim +import Lightning.Protocol.BOLT4.Types + +-- | Route information for a single hop. +data Hop = Hop + { hopPubKey :: !Secp256k1.Projective -- ^ node's public key + , hopPayload :: !HopPayload -- ^ routing data for this hop + } deriving (Eq, Show) + +-- | Errors during packet construction. +data Error + = InvalidSessionKey + | EmptyRoute + | TooManyHops + | PayloadTooLarge !Int + | InvalidHopPubKey !Int + deriving (Eq, Show) + +-- | Maximum number of hops in a route. +maxHops :: Int +maxHops = 20 +{-# INLINE maxHops #-} + +-- | Construct an onion packet for a payment route. +-- +-- Takes a session key (32 bytes random), list of hops, and associated +-- data (typically payment_hash). +-- +-- Returns the onion packet and list of shared secrets (for error +-- attribution). +construct + :: BS.ByteString -- ^ 32-byte session key (random) + -> [Hop] -- ^ route (first hop to final destination) + -> BS.ByteString -- ^ associated data + -> Either Error (OnionPacket, [SharedSecret]) +construct !sessionKey !hops !assocData + | BS.length sessionKey /= 32 = Left InvalidSessionKey + | null hops = Left EmptyRoute + | length hops > maxHops = Left TooManyHops + | otherwise = do + -- Initialize ephemeral keypair from session key + ephSec <- maybe (Left InvalidSessionKey) Right + (Secp256k1.roll32 sessionKey) + ephPub <- maybe (Left InvalidSessionKey) Right + (Secp256k1.derive_pub ephSec) + + -- Compute shared secrets and blinding factors for all hops + let hopPubKeys = map hopPubKey hops + (secrets, _) <- computeAllSecrets sessionKey ephPub hopPubKeys + + -- Validate payload sizes + let payloadBytes = map (encodeHopPayload . hopPayload) hops + payloadSizes = map payloadShiftSize payloadBytes + totalSize = sum payloadSizes + if totalSize > hopPayloadsSize + then Left (PayloadTooLarge totalSize) + else do + -- Generate filler using secrets for all but final hop + let numHops = length hops + secretsExceptFinal = take (numHops - 1) secrets + sizesExceptFinal = take (numHops - 1) payloadSizes + filler = generateFiller secretsExceptFinal sizesExceptFinal + + -- Initialize hop_payloads with deterministic padding + let DerivedKey padKey = derivePad (SharedSecret sessionKey) + initialPayloads = generateStream (DerivedKey padKey) + hopPayloadsSize + + -- Wrap payloads in reverse order (final hop first) + let (finalPayloads, finalHmac) = wrapAllHops + secrets payloadBytes filler assocData initialPayloads + + -- Build the final packet + let ephPubBytes = Secp256k1.serialize_point ephPub + packet = OnionPacket + { opVersion = versionByte + , opEphemeralKey = ephPubBytes + , opHopPayloads = finalPayloads + , opHmac = finalHmac + } + + Right (packet, secrets) + +-- | Compute the total shift size for a payload. +payloadShiftSize :: BS.ByteString -> Int +payloadShiftSize !payload = + let !len = BS.length payload + !bsLen = bigSizeLen (fromIntegral len) + in bsLen + len + hmacSize +{-# INLINE payloadShiftSize #-} + +-- | Compute shared secrets for all hops. +computeAllSecrets + :: BS.ByteString + -> Secp256k1.Projective + -> [Secp256k1.Projective] + -> Either Error ([SharedSecret], Secp256k1.Projective) +computeAllSecrets !initSec !initPub = go initSec initPub 0 [] + where + go !_ephSec !ephPub !_ !acc [] = Right (reverse acc, ephPub) + go !ephSec !ephPub !idx !acc (hopPub:rest) = do + ss <- maybe (Left (InvalidHopPubKey idx)) Right + (computeSharedSecret ephSec hopPub) + let !bf = computeBlindingFactor ephPub ss + newEphSec <- maybe (Left (InvalidHopPubKey idx)) Right + (blindSecKey ephSec bf) + newEphPub <- maybe (Left (InvalidHopPubKey idx)) Right + (blindPubKey ephPub bf) + go newEphSec newEphPub (idx + 1) (ss : acc) rest + +-- | Generate filler bytes. +generateFiller :: [SharedSecret] -> [Int] -> BS.ByteString +generateFiller !secrets !sizes = go BS.empty secrets sizes + where + go !filler [] [] = filler + go !filler (ss:sss) (sz:szs) = + let !extended = filler <> BS.replicate sz 0 + !rhoKey = deriveRho ss + !stream = generateStream rhoKey (2 * hopPayloadsSize) + !streamOffset = hopPayloadsSize + !streamPart = BS.take (BS.length extended) + (BS.drop streamOffset stream) + !newFiller = xorBytes extended streamPart + in go newFiller sss szs + go !filler _ _ = filler +{-# INLINE generateFiller #-} + +-- | Wrap all hops in reverse order. +wrapAllHops + :: [SharedSecret] + -> [BS.ByteString] + -> BS.ByteString + -> BS.ByteString + -> BS.ByteString + -> (BS.ByteString, BS.ByteString) +wrapAllHops !secrets !payloads !filler !assocData !initPayloads = + let !paired = reverse (zip secrets payloads) + !numHops = length paired + !initHmac = BS.replicate hmacSize 0 + in go numHops initPayloads initHmac paired + where + go !_ !hopPayloads !hmac [] = (hopPayloads, hmac) + go !remaining !hopPayloads !hmac ((ss, payload):rest) = + let !isLastHop = remaining == length (reverse (zip secrets payloads)) + (!newPayloads, !newHmac) = wrapHop ss payload hmac hopPayloads + assocData filler isLastHop + in go (remaining - 1) newPayloads newHmac rest + +-- | Wrap a single hop's payload. +wrapHop + :: SharedSecret + -> BS.ByteString + -> BS.ByteString + -> BS.ByteString + -> BS.ByteString + -> BS.ByteString + -> Bool + -> (BS.ByteString, BS.ByteString) +wrapHop !ss !payload !hmac !hopPayloads !assocData !filler !isFinalHop = + let !payloadLen = BS.length payload + !lenBytes = encodeBigSize (fromIntegral payloadLen) + !shiftSize = BS.length lenBytes + payloadLen + hmacSize + !shifted = BS.take (hopPayloadsSize - shiftSize) hopPayloads + !prepended = lenBytes <> payload <> hmac <> shifted + !rhoKey = deriveRho ss + !stream = generateStream rhoKey hopPayloadsSize + !obfuscated = xorBytes prepended stream + !withFiller = if isFinalHop && not (BS.null filler) + then applyFiller obfuscated filler + else obfuscated + !muKey = deriveMu ss + !newHmac = computeHmac muKey withFiller assocData + in (withFiller, newHmac) +{-# INLINE wrapHop #-} + +-- | Apply filler to the tail of hop_payloads. +applyFiller :: BS.ByteString -> BS.ByteString -> BS.ByteString +applyFiller !hopPayloads !filler = + let !fillerLen = BS.length filler + !prefix = BS.take (hopPayloadsSize - fillerLen) hopPayloads + in prefix <> filler +{-# INLINE applyFiller #-} + +-- | XOR two ByteStrings. +xorBytes :: BS.ByteString -> BS.ByteString -> BS.ByteString +xorBytes !a !b = BS.pack $ BS.zipWith xor a b +{-# INLINE xorBytes #-} diff --git a/ppad-bolt4.cabal b/ppad-bolt4.cabal @@ -25,6 +25,7 @@ library exposed-modules: Lightning.Protocol.BOLT4 Lightning.Protocol.BOLT4.Codec + Lightning.Protocol.BOLT4.Construct Lightning.Protocol.BOLT4.Prim Lightning.Protocol.BOLT4.Types build-depends: diff --git a/test/Main.hs b/test/Main.hs @@ -6,6 +6,7 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 import qualified Crypto.Curve.Secp256k1 as Secp256k1 import Lightning.Protocol.BOLT4.Codec +import Lightning.Protocol.BOLT4.Construct import Lightning.Protocol.BOLT4.Prim import Lightning.Protocol.BOLT4.Types import Test.Tasty @@ -30,6 +31,9 @@ main = defaultMain $ testGroup "ppad-bolt4" [ , testGroup "OnionPacket" [ onionPacketTests ] + , testGroup "Construct" [ + constructTests + ] ] -- BigSize tests ------------------------------------------------------------ @@ -321,3 +325,151 @@ testHmacOperations = testGroup "HMAC operations" [ assertBool "verifyHmac should fail" (not $ verifyHmac "short" "different length") ] + +-- Construct tests ------------------------------------------------------------ + +-- Test vectors from BOLT4 spec +hop1PubKeyHex :: BS.ByteString +hop1PubKeyHex = + "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + +hop2PubKeyHex :: BS.ByteString +hop2PubKeyHex = + "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007" + +hop3PubKeyHex :: BS.ByteString +hop3PubKeyHex = + "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991" + +hop4PubKeyHex :: BS.ByteString +hop4PubKeyHex = + "02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145" + +-- Expected shared secrets from BOLT4 error test vectors (in route order) +hop1SharedSecretHex :: BS.ByteString +hop1SharedSecretHex = + "a6519e98832a0b179f62123b3567c106db99ee37bef036e783263602f3488fae" + +hop2SharedSecretHex :: BS.ByteString +hop2SharedSecretHex = + "3a6b412548762f0dbccce5c7ae7bb8147d1caf9b5471c34120b30bc9c04891cc" + +hop3SharedSecretHex :: BS.ByteString +hop3SharedSecretHex = + "21e13c2d7cfe7e18836df50872466117a295783ab8aab0e7ecc8c725503ad02d" + +hop4SharedSecretHex :: BS.ByteString +hop4SharedSecretHex = + "b5756b9b542727dbafc6765a49488b023a725d631af688fc031217e90770c328" + +constructTests :: TestTree +constructTests = testGroup "packet construction" [ + testConstructErrorCases + , testSharedSecretComputation + , testPacketStructure + , testSingleHop + ] + +testConstructErrorCases :: TestTree +testConstructErrorCases = testGroup "error cases" [ + testCase "rejects invalid session key (too short)" $ do + let result = construct (BS.replicate 31 0x41) [] "" + case result of + Left InvalidSessionKey -> return () + _ -> assertFailure "Expected InvalidSessionKey" + , testCase "rejects invalid session key (too long)" $ do + let result = construct (BS.replicate 33 0x41) [] "" + case result of + Left InvalidSessionKey -> return () + _ -> assertFailure "Expected InvalidSessionKey" + , testCase "rejects empty route" $ do + let result = construct sessionKey [] "" + case result of + Left EmptyRoute -> return () + _ -> assertFailure "Expected EmptyRoute" + , testCase "rejects too many hops" $ do + let Just pub = Secp256k1.parse_point (fromHex hop0PubKeyHex) + emptyPayload = HopPayload Nothing Nothing Nothing Nothing + Nothing Nothing [] + hop = Hop pub emptyPayload + hops = replicate 21 hop + result = construct sessionKey hops "" + case result of + Left TooManyHops -> return () + _ -> assertFailure "Expected TooManyHops" + ] + +testSharedSecretComputation :: TestTree +testSharedSecretComputation = + testCase "computes correct shared secrets (BOLT4 spec)" $ do + let Just pub0 = Secp256k1.parse_point (fromHex hop0PubKeyHex) + Just pub1 = Secp256k1.parse_point (fromHex hop1PubKeyHex) + Just pub2 = Secp256k1.parse_point (fromHex hop2PubKeyHex) + Just pub3 = Secp256k1.parse_point (fromHex hop3PubKeyHex) + Just pub4 = Secp256k1.parse_point (fromHex hop4PubKeyHex) + emptyPayload = HopPayload Nothing Nothing Nothing Nothing + Nothing Nothing [] + hops = [ Hop pub0 emptyPayload + , Hop pub1 emptyPayload + , Hop pub2 emptyPayload + , Hop pub3 emptyPayload + , Hop pub4 emptyPayload + ] + result = construct sessionKey hops "" + case result of + Left err -> assertFailure $ "construct failed: " ++ show err + Right (_, secrets) -> do + length secrets @?= 5 + let [SharedSecret ss0, SharedSecret ss1, SharedSecret ss2, + SharedSecret ss3, SharedSecret ss4] = secrets + ss0 @?= fromHex hop0SharedSecretHex + ss1 @?= fromHex hop1SharedSecretHex + ss2 @?= fromHex hop2SharedSecretHex + ss3 @?= fromHex hop3SharedSecretHex + ss4 @?= fromHex hop4SharedSecretHex + +testPacketStructure :: TestTree +testPacketStructure = testCase "produces valid packet structure" $ do + let Just pub0 = Secp256k1.parse_point (fromHex hop0PubKeyHex) + Just pub1 = Secp256k1.parse_point (fromHex hop1PubKeyHex) + emptyPayload = HopPayload Nothing Nothing Nothing Nothing + Nothing Nothing [] + hops = [Hop pub0 emptyPayload, Hop pub1 emptyPayload] + result = construct sessionKey hops "" + case result of + Left err -> assertFailure $ "construct failed: " ++ show err + Right (packet, _) -> do + opVersion packet @?= versionByte + BS.length (opEphemeralKey packet) @?= pubkeySize + BS.length (opHopPayloads packet) @?= hopPayloadsSize + BS.length (opHmac packet) @?= hmacSize + -- The ephemeral key should be the public key derived from session key + let Just sk = Secp256k1.roll32 sessionKey + Just expectedPub = Secp256k1.derive_pub sk + expectedPubBytes = Secp256k1.serialize_point expectedPub + opEphemeralKey packet @?= expectedPubBytes + +testSingleHop :: TestTree +testSingleHop = testCase "constructs single-hop packet" $ do + let Just pub0 = Secp256k1.parse_point (fromHex hop0PubKeyHex) + payload = HopPayload + { hpAmtToForward = Just 1000 + , hpOutgoingCltv = Just 500000 + , hpShortChannelId = Nothing + , hpPaymentData = Nothing + , hpEncryptedData = Nothing + , hpCurrentPathKey = Nothing + , hpUnknownTlvs = [] + } + hops = [Hop pub0 payload] + result = construct sessionKey hops "" + case result of + Left err -> assertFailure $ "construct failed: " ++ show err + Right (packet, secrets) -> do + length secrets @?= 1 + -- Packet should be valid structure + let encoded = encodeOnionPacket packet + BS.length encoded @?= onionPacketSize + -- Should decode back + let Just decoded = decodeOnionPacket encoded + decoded @?= packet