packages feed

ppad-bolt4-0.0.1: lib/Lightning/Protocol/BOLT4/Construct.hs

{-# 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 #-}