packages feed

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

{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module: Lightning.Protocol.BOLT4.Error
-- Copyright: (c) 2025 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Error packet construction and unwrapping for BOLT4 onion routing.
--
-- Failing nodes construct error packets that are wrapped at each
-- intermediate hop on the return path. The origin node unwraps
-- layers to attribute the error to a specific hop.

module Lightning.Protocol.BOLT4.Error (
    -- * Types
    ErrorPacket(..)
  , AttributionResult(..)
  , minErrorPacketSize

    -- * Error construction (failing node)
  , constructError

    -- * Error forwarding (intermediate node)
  , wrapError

    -- * Error unwrapping (origin node)
  , unwrapError
  ) where

import Data.Bits (xor)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Lazy as BL
import qualified Crypto.Hash.SHA256 as SHA256
import Data.Word (Word8, Word16)
import Lightning.Protocol.BOLT4.Codec (encodeFailureMessage, decodeFailureMessage)
import Lightning.Protocol.BOLT4.Prim
import Lightning.Protocol.BOLT4.Types (FailureMessage)

-- | Wrapped error packet ready for return to origin.
newtype ErrorPacket = ErrorPacket BS.ByteString
  deriving (Eq, Show)

-- | Result of error attribution.
data AttributionResult
  = Attributed {-# UNPACK #-} !Int !FailureMessage
    -- ^ (hop index, failure)
  | UnknownOrigin !BS.ByteString
    -- ^ Could not attribute to any hop
  deriving (Eq, Show)

-- | Minimum error packet size (256 bytes per spec).
minErrorPacketSize :: Int
minErrorPacketSize = 256
{-# INLINE minErrorPacketSize #-}

-- Error construction ---------------------------------------------------------

-- | Construct an error packet at a failing node.
--
-- Takes the shared secret (from processing) and failure message,
-- and wraps it for return to origin.
constructError
  :: SharedSecret      -- ^ from packet processing
  -> FailureMessage    -- ^ failure details
  -> ErrorPacket
constructError !ss !failure =
  let !um = deriveUm ss
      !ammag = deriveAmmag ss
      !inner = buildErrorMessage um failure
      !obfuscated = obfuscateError ammag inner
  in  ErrorPacket obfuscated
{-# INLINE constructError #-}

-- | Wrap an existing error packet for forwarding back.
--
-- Each intermediate node wraps the error with its own layer.
wrapError
  :: SharedSecret      -- ^ this node's shared secret
  -> ErrorPacket       -- ^ error from downstream
  -> ErrorPacket
wrapError !ss (ErrorPacket !packet) =
  let !ammag = deriveAmmag ss
      !wrapped = obfuscateError ammag packet
  in  ErrorPacket wrapped
{-# INLINE wrapError #-}

-- Error unwrapping -----------------------------------------------------------

-- | Attempt to attribute an error to a specific hop.
--
-- Takes the shared secrets from original packet construction
-- (in order from first hop to final) and the error packet.
--
-- Tries each hop's keys until HMAC verifies, revealing origin.
unwrapError
  :: [SharedSecret]    -- ^ secrets from construction, in route order
  -> ErrorPacket       -- ^ received error
  -> AttributionResult
unwrapError secrets (ErrorPacket !initialPacket) = go 0 initialPacket secrets
  where
    go :: Int -> BS.ByteString -> [SharedSecret] -> AttributionResult
    go !_ !packet [] = UnknownOrigin packet
    go !idx !packet (ss:rest) =
      let !ammag = deriveAmmag ss
          !um = deriveUm ss
          !deobfuscated = deobfuscateError ammag packet
      in  if verifyErrorHmac um deobfuscated
            then case parseErrorMessage (BS.drop 32 deobfuscated) of
                   Just msg -> Attributed idx msg
                   Nothing  -> UnknownOrigin deobfuscated
            else go (idx + 1) deobfuscated rest

-- Internal functions ---------------------------------------------------------

-- | Build the inner error message structure.
--
-- Format: HMAC (32) || len (2) || message || pad_len (2) || padding
-- Total must be >= 256 bytes.
buildErrorMessage
  :: DerivedKey        -- ^ um key
  -> FailureMessage    -- ^ failure to encode
  -> BS.ByteString     -- ^ complete message with HMAC
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)
      !padding = BS.replicate padLen 0
      -- Build: len || message || pad_len || padding
      !payload = toStrict $
        B.word16BE (fromIntegral msgLen) <>
        B.byteString encoded <>
        B.word16BE (fromIntegral padLen) <>
        B.byteString padding
      -- HMAC over the payload
      SHA256.MAC !hmac = SHA256.hmac umKey payload
  in  hmac <> payload
{-# INLINE buildErrorMessage #-}

-- | Obfuscate error packet with ammag stream.
--
-- XORs the entire packet with pseudo-random stream.
obfuscateError
  :: DerivedKey        -- ^ ammag key
  -> BS.ByteString     -- ^ error packet
  -> BS.ByteString     -- ^ obfuscated packet
obfuscateError !ammag !packet =
  let !stream = generateStream ammag (BS.length packet)
  in  xorBytes packet stream
{-# INLINE obfuscateError #-}

-- | Remove one layer of obfuscation from error packet.
--
-- XOR is its own inverse, so same as obfuscation.
deobfuscateError
  :: DerivedKey        -- ^ ammag key
  -> BS.ByteString     -- ^ obfuscated packet
  -> BS.ByteString     -- ^ deobfuscated packet
deobfuscateError = obfuscateError
{-# INLINE deobfuscateError #-}

-- | Verify error HMAC after deobfuscation.
verifyErrorHmac
  :: DerivedKey        -- ^ um key
  -> BS.ByteString     -- ^ deobfuscated packet (HMAC || rest)
  -> Bool
verifyErrorHmac (DerivedKey !umKey) !packet
  | BS.length packet < 32 = False
  | otherwise =
      let !receivedHmac = BS.take 32 packet
          !payload = BS.drop 32 packet
          SHA256.MAC !computedHmac = SHA256.hmac umKey payload
      in  constantTimeEq receivedHmac computedHmac
{-# INLINE verifyErrorHmac #-}

-- | Parse error message from deobfuscated packet (after HMAC).
parseErrorMessage
  :: BS.ByteString     -- ^ packet after HMAC (len || msg || pad_len || pad)
  -> Maybe FailureMessage
parseErrorMessage !bs
  | BS.length bs < 4 = Nothing
  | otherwise =
      let !msgLen = fromIntegral (word16BE (BS.take 2 bs))
      in  if BS.length bs < 2 + msgLen
            then Nothing
            else decodeFailureMessage (BS.take msgLen (BS.drop 2 bs))
{-# INLINE parseErrorMessage #-}

-- Helper functions -----------------------------------------------------------

-- | XOR two ByteStrings of equal length.
xorBytes :: BS.ByteString -> BS.ByteString -> BS.ByteString
xorBytes !a !b = BS.pack $ BS.zipWith xor a b
{-# INLINE xorBytes #-}

-- | Constant-time equality comparison.
constantTimeEq :: BS.ByteString -> BS.ByteString -> Bool
constantTimeEq !a !b
  | BS.length a /= BS.length b = False
  | otherwise = go 0 (BS.zip a b)
  where
    go :: Word8 -> [(Word8, Word8)] -> Bool
    go !acc [] = acc == 0
    go !acc ((x, y):rest) = go (acc `xor` (x `xor` y)) rest
{-# INLINE constantTimeEq #-}

-- | Decode big-endian Word16.
word16BE :: BS.ByteString -> Word16
word16BE !bs =
  let !b0 = fromIntegral (BS.index bs 0) :: Word16
      !b1 = fromIntegral (BS.index bs 1) :: Word16
  in  (b0 * 256) + b1
{-# INLINE word16BE #-}

-- | Convert Builder to strict ByteString.
toStrict :: B.Builder -> BS.ByteString
toStrict = BL.toStrict . B.toLazyByteString
{-# INLINE toStrict #-}