ppad-bolt4-0.0.1: lib/Lightning/Protocol/BOLT4/Blinding.hs
{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module: Lightning.Protocol.BOLT4.Blinding
-- Copyright: (c) 2025 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Route blinding for BOLT4 onion routing.
module Lightning.Protocol.BOLT4.Blinding (
-- * Types
BlindedPath(..)
, BlindedHop(..)
, BlindedHopData(..)
, PaymentRelay(..)
, PaymentConstraints(..)
, BlindingError(..)
-- * Path creation
, createBlindedPath
-- * Hop processing
, processBlindedHop
-- * Key derivation (exported for testing)
, deriveBlindingRho
, deriveBlindedNodeId
, nextEphemeral
-- * TLV encoding (exported for testing)
, encodeBlindedHopData
, decodeBlindedHopData
-- * Encryption (exported for testing)
, encryptHopData
, decryptHopData
) where
import qualified Crypto.AEAD.ChaCha20Poly1305 as AEAD
import qualified Crypto.Curve.Secp256k1 as Secp256k1
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import Data.Word (Word16, Word32, Word64)
import qualified Numeric.Montgomery.Secp256k1.Scalar as S
import Lightning.Protocol.BOLT4.Codec
( encodeShortChannelId, decodeShortChannelId
, encodeTlvStream, decodeTlvStream
, toStrict, word16BE, word32BE
, encodeWord64TU, decodeWord64TU
, encodeWord32TU, decodeWord32TU
)
import Lightning.Protocol.BOLT4.Prim (SharedSecret(..), DerivedKey(..))
import Lightning.Protocol.BOLT4.Types (ShortChannelId(..), TlvRecord(..))
-- Types ---------------------------------------------------------------------
-- | A blinded route provided by recipient.
data BlindedPath = BlindedPath
{ bpIntroductionNode :: !Secp256k1.Projective -- ^ First node (unblinded)
, bpBlindingKey :: !Secp256k1.Projective -- ^ E_0, initial ephemeral
, bpBlindedHops :: ![BlindedHop]
} deriving (Eq, Show)
-- | A single hop in a blinded path.
data BlindedHop = BlindedHop
{ bhBlindedNodeId :: !BS.ByteString -- ^ 33 bytes, blinded pubkey
, bhEncryptedData :: !BS.ByteString -- ^ Encrypted routing data
} deriving (Eq, Show)
-- | Data encrypted for each blinded hop (before encryption).
data BlindedHopData = BlindedHopData
{ bhdPadding :: !(Maybe BS.ByteString) -- ^ TLV 1
, bhdShortChannelId :: !(Maybe ShortChannelId) -- ^ TLV 2
, bhdNextNodeId :: !(Maybe BS.ByteString) -- ^ TLV 4, 33-byte pubkey
, bhdPathId :: !(Maybe BS.ByteString) -- ^ TLV 6
, bhdNextPathKeyOverride :: !(Maybe BS.ByteString) -- ^ TLV 8
, bhdPaymentRelay :: !(Maybe PaymentRelay) -- ^ TLV 10
, bhdPaymentConstraints :: !(Maybe PaymentConstraints) -- ^ TLV 12
, bhdAllowedFeatures :: !(Maybe BS.ByteString) -- ^ TLV 14
} deriving (Eq, Show)
-- | Payment relay parameters (TLV 10).
data PaymentRelay = PaymentRelay
{ prCltvExpiryDelta :: {-# UNPACK #-} !Word16
, prFeeProportional :: {-# UNPACK #-} !Word32 -- ^ Fee in millionths
, prFeeBaseMsat :: {-# UNPACK #-} !Word32
} deriving (Eq, Show)
-- | Payment constraints (TLV 12).
data PaymentConstraints = PaymentConstraints
{ pcMaxCltvExpiry :: {-# UNPACK #-} !Word32
, pcHtlcMinimumMsat :: {-# UNPACK #-} !Word64
} deriving (Eq, Show)
-- | Errors during blinding operations.
data BlindingError
= InvalidSeed
| EmptyPath
| InvalidNodeKey Int
| DecryptionFailed
| InvalidPathKey
deriving (Eq, Show)
-- Key derivation ------------------------------------------------------------
-- | Derive rho key for encrypting hop data.
--
-- @rho = HMAC-SHA256(key="rho", data=shared_secret)@
deriveBlindingRho :: SharedSecret -> DerivedKey
deriveBlindingRho (SharedSecret !ss) =
let SHA256.MAC !result = SHA256.hmac "rho" ss
in DerivedKey result
{-# INLINE deriveBlindingRho #-}
-- | Derive blinded node ID from shared secret and node pubkey.
--
-- @B_i = HMAC256("blinded_node_id", ss_i) * N_i@
deriveBlindedNodeId
:: SharedSecret
-> Secp256k1.Projective
-> Maybe BS.ByteString
deriveBlindedNodeId (SharedSecret !ss) !nodePub = do
let SHA256.MAC !hmacResult = SHA256.hmac "blinded_node_id" ss
sk <- Secp256k1.roll32 hmacResult
blindedPub <- Secp256k1.mul nodePub sk
pure $! Secp256k1.serialize_point blindedPub
{-# INLINE deriveBlindedNodeId #-}
-- | Compute next ephemeral key pair.
--
-- @e_{i+1} = SHA256(E_i || ss_i) * e_i@
-- @E_{i+1} = SHA256(E_i || ss_i) * E_i@
nextEphemeral
:: BS.ByteString -- ^ e_i (32-byte secret key)
-> Secp256k1.Projective -- ^ E_i
-> SharedSecret -- ^ ss_i
-> Maybe (BS.ByteString, Secp256k1.Projective) -- ^ (e_{i+1}, E_{i+1})
nextEphemeral !secKey !pubKey (SharedSecret !ss) = do
let !pubBytes = Secp256k1.serialize_point pubKey
!blindingFactor = SHA256.hash (pubBytes <> ss)
bfInt <- Secp256k1.roll32 blindingFactor
-- Compute e_{i+1} = e_i * blindingFactor (mod q)
let !newSecKey = mulSecKey secKey blindingFactor
-- Compute E_{i+1} = E_i * blindingFactor
newPubKey <- Secp256k1.mul pubKey bfInt
pure (newSecKey, newPubKey)
{-# INLINE nextEphemeral #-}
-- | Compute blinding factor for next path key (public key only).
nextPathKey
:: Secp256k1.Projective -- ^ E_i
-> SharedSecret -- ^ ss_i
-> Maybe Secp256k1.Projective -- ^ E_{i+1}
nextPathKey !pubKey (SharedSecret !ss) = do
let !pubBytes = Secp256k1.serialize_point pubKey
!blindingFactor = SHA256.hash (pubBytes <> ss)
bfInt <- Secp256k1.roll32 blindingFactor
Secp256k1.mul pubKey bfInt
{-# INLINE nextPathKey #-}
-- Encryption/Decryption -----------------------------------------------------
-- | Encrypt hop data with ChaCha20-Poly1305.
--
-- Uses rho key and 12-byte zero nonce, empty AAD.
encryptHopData :: DerivedKey -> BlindedHopData -> BS.ByteString
encryptHopData (DerivedKey !rho) !hopData =
let !plaintext = encodeBlindedHopData hopData
!nonce = BS.replicate 12 0
in case AEAD.encrypt BS.empty rho nonce plaintext of
Left e -> error $ "encryptHopData: unexpected AEAD error: " ++ show e
Right (!ciphertext, !mac) -> ciphertext <> mac
{-# INLINE encryptHopData #-}
-- | Decrypt hop data with ChaCha20-Poly1305.
decryptHopData :: DerivedKey -> BS.ByteString -> Maybe BlindedHopData
decryptHopData (DerivedKey !rho) !encData
| BS.length encData < 16 = Nothing
| otherwise = do
let !ciphertext = BS.take (BS.length encData - 16) encData
!mac = BS.drop (BS.length encData - 16) encData
!nonce = BS.replicate 12 0
case AEAD.decrypt BS.empty rho nonce (ciphertext, mac) of
Left _ -> Nothing
Right !plaintext -> decodeBlindedHopData plaintext
{-# INLINE decryptHopData #-}
-- TLV Encoding/Decoding -----------------------------------------------------
-- | Encode BlindedHopData to TLV stream.
encodeBlindedHopData :: BlindedHopData -> BS.ByteString
encodeBlindedHopData !bhd = encodeTlvStream (buildTlvs bhd)
where
buildTlvs :: BlindedHopData -> [TlvRecord]
buildTlvs (BlindedHopData pad sci nid pid pko pr pc af) =
let pad' = maybe [] (\p -> [TlvRecord 1 p]) pad
sci' = maybe [] (\s -> [TlvRecord 2 (encodeShortChannelId s)]) sci
nid' = maybe [] (\n -> [TlvRecord 4 n]) nid
pid' = maybe [] (\p -> [TlvRecord 6 p]) pid
pko' = maybe [] (\k -> [TlvRecord 8 k]) pko
pr' = maybe [] (\r -> [TlvRecord 10 (encodePaymentRelay r)]) pr
pc' = maybe [] (\c -> [TlvRecord 12 (encodePaymentConstraints c)]) pc
af' = maybe [] (\f -> [TlvRecord 14 f]) af
in pad' ++ sci' ++ nid' ++ pid' ++ pko' ++ pr' ++ pc' ++ af'
{-# INLINE encodeBlindedHopData #-}
-- | Decode TLV stream to BlindedHopData.
decodeBlindedHopData :: BS.ByteString -> Maybe BlindedHopData
decodeBlindedHopData !bs = do
tlvs <- decodeTlvStream bs
parseBlindedHopData tlvs
parseBlindedHopData :: [TlvRecord] -> Maybe BlindedHopData
parseBlindedHopData = go emptyHopData
where
emptyHopData :: BlindedHopData
emptyHopData = BlindedHopData
Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing
go :: BlindedHopData -> [TlvRecord] -> Maybe BlindedHopData
go !bhd [] = Just bhd
go !bhd (TlvRecord typ val : rest) = case typ of
1 -> go bhd { bhdPadding = Just val } rest
2 -> do
sci <- decodeShortChannelId val
go bhd { bhdShortChannelId = Just sci } rest
4 -> go bhd { bhdNextNodeId = Just val } rest
6 -> go bhd { bhdPathId = Just val } rest
8 -> go bhd { bhdNextPathKeyOverride = Just val } rest
10 -> do
pr <- decodePaymentRelay val
go bhd { bhdPaymentRelay = Just pr } rest
12 -> do
pc <- decodePaymentConstraints val
go bhd { bhdPaymentConstraints = Just pc } rest
14 -> go bhd { bhdAllowedFeatures = Just val } rest
_ -> go bhd rest -- Skip unknown TLVs
-- PaymentRelay encoding/decoding --------------------------------------------
-- | Encode PaymentRelay.
--
-- Format: 2-byte cltv_delta BE, 4-byte fee_prop BE, tu32 fee_base
encodePaymentRelay :: PaymentRelay -> BS.ByteString
encodePaymentRelay (PaymentRelay !cltv !feeProp !feeBase) = toStrict $
B.word16BE cltv <>
B.word32BE feeProp <>
B.byteString (encodeWord32TU feeBase)
{-# INLINE encodePaymentRelay #-}
-- | Decode PaymentRelay.
decodePaymentRelay :: BS.ByteString -> Maybe PaymentRelay
decodePaymentRelay !bs
| BS.length bs < 6 = Nothing
| otherwise = do
let !cltv = word16BE (BS.take 2 bs)
!feeProp = word32BE (BS.take 4 (BS.drop 2 bs))
!feeBaseBytes = BS.drop 6 bs
feeBase <- decodeWord32TU feeBaseBytes
Just (PaymentRelay cltv feeProp feeBase)
{-# INLINE decodePaymentRelay #-}
-- PaymentConstraints encoding/decoding --------------------------------------
-- | Encode PaymentConstraints.
--
-- Format: 4-byte max_cltv BE, tu64 htlc_min
encodePaymentConstraints :: PaymentConstraints -> BS.ByteString
encodePaymentConstraints (PaymentConstraints !maxCltv !htlcMin) = toStrict $
B.word32BE maxCltv <>
B.byteString (encodeWord64TU htlcMin)
{-# INLINE encodePaymentConstraints #-}
-- | Decode PaymentConstraints.
decodePaymentConstraints :: BS.ByteString -> Maybe PaymentConstraints
decodePaymentConstraints !bs
| BS.length bs < 4 = Nothing
| otherwise = do
let !maxCltv = word32BE (BS.take 4 bs)
!htlcMinBytes = BS.drop 4 bs
htlcMin <- decodeWord64TU htlcMinBytes
Just (PaymentConstraints maxCltv htlcMin)
{-# INLINE decodePaymentConstraints #-}
-- Shared secret computation -------------------------------------------------
-- | Compute shared secret from ECDH.
computeSharedSecret
:: BS.ByteString -- ^ 32-byte secret key
-> Secp256k1.Projective -- ^ Public key
-> Maybe SharedSecret
computeSharedSecret !secBs !pub = do
sec <- Secp256k1.roll32 secBs
ecdhPoint <- Secp256k1.mul pub sec
let !compressed = Secp256k1.serialize_point ecdhPoint
!ss = SHA256.hash compressed
pure $! SharedSecret ss
{-# INLINE computeSharedSecret #-}
-- Path creation -------------------------------------------------------------
-- | Create a blinded path from a seed and list of nodes with their data.
createBlindedPath
:: BS.ByteString -- ^ 32-byte random seed for ephemeral key
-> [(Secp256k1.Projective, BlindedHopData)] -- ^ Nodes with their data
-> Either BlindingError BlindedPath
createBlindedPath !seed !nodes
| BS.length seed /= 32 = Left InvalidSeed
| otherwise = case nodes of
[] -> Left EmptyPath
((introNode, _) : _) -> do
-- (e_0, E_0) = keypair from seed
e0 <- maybe (Left InvalidSeed) Right (Secp256k1.roll32 seed)
e0Pub <- maybe (Left InvalidSeed) Right
(Secp256k1.mul Secp256k1._CURVE_G e0)
-- Process all hops
hops <- processHops seed e0Pub nodes 0
Right (BlindedPath introNode e0Pub hops)
processHops
:: BS.ByteString -- ^ Current e_i
-> Secp256k1.Projective -- ^ Current E_i
-> [(Secp256k1.Projective, BlindedHopData)]
-> Int -- ^ Index for error reporting
-> Either BlindingError [BlindedHop]
processHops _ _ [] _ = Right []
processHops !eKey !ePub ((nodePub, hopData) : rest) !idx = do
-- ss_i = SHA256(ECDH(e_i, N_i))
ss <- maybe (Left (InvalidNodeKey idx)) Right
(computeSharedSecret eKey nodePub)
-- rho_i = deriveBlindingRho(ss_i)
let !rho = deriveBlindingRho ss
-- B_i = deriveBlindedNodeId(ss_i, N_i)
blindedId <- maybe (Left (InvalidNodeKey idx)) Right
(deriveBlindedNodeId ss nodePub)
-- encrypted_i = encryptHopData(rho_i, data_i)
let !encData = encryptHopData rho hopData
!hop = BlindedHop blindedId encData
-- (e_{i+1}, E_{i+1}) = nextEphemeral(e_i, E_i, ss_i)
(nextE, nextEPub) <- maybe (Left (InvalidNodeKey idx)) Right
(nextEphemeral eKey ePub ss)
-- Process remaining hops
restHops <- processHops nextE nextEPub rest (idx + 1)
Right (hop : restHops)
-- Hop processing ------------------------------------------------------------
-- | Process a blinded hop, returning decrypted data and next path key.
processBlindedHop
:: BS.ByteString -- ^ Node's 32-byte private key
-> Secp256k1.Projective -- ^ E_i, current path key (blinding point)
-> BS.ByteString -- ^ encrypted_data from onion payload
-> Either BlindingError (BlindedHopData, Secp256k1.Projective)
processBlindedHop !nodeSecKey !pathKey !encData = do
-- ss = SHA256(ECDH(node_seckey, path_key))
ss <- maybe (Left InvalidPathKey) Right
(computeSharedSecret nodeSecKey pathKey)
-- rho = deriveBlindingRho(ss)
let !rho = deriveBlindingRho ss
-- hop_data = decryptHopData(rho, encrypted_data)
hopData <- maybe (Left DecryptionFailed) Right
(decryptHopData rho encData)
-- Compute next path key
nextKey <- case bhdNextPathKeyOverride hopData of
Just override -> do
-- Parse override as compressed point
maybe (Left InvalidPathKey) Right (Secp256k1.parse_point override)
Nothing -> do
-- E_next = SHA256(path_key || ss) * path_key
maybe (Left InvalidPathKey) Right (nextPathKey pathKey ss)
Right (hopData, nextKey)
-- Scalar multiplication -----------------------------------------------------
-- | Multiply two 32-byte scalars mod curve order q.
--
-- Uses Montgomery multiplication from ppad-fixed for efficiency.
mulSecKey :: BS.ByteString -> BS.ByteString -> BS.ByteString
mulSecKey !a !b =
let !aW = Secp256k1.unsafe_roll32 a
!bW = Secp256k1.unsafe_roll32 b
!aM = S.to aW
!bM = S.to bW
!resultM = S.mul aM bM
!resultW = S.retr resultM
in Secp256k1.unroll32 resultW
{-# INLINE mulSecKey #-}