bolt4

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

commit bba3bab650ff572546b91c3d3330203a259331a5
parent 568a16319d667b7ccdfcf0c4a8dcf19a963039be
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 14:53:56 +0400

lib: port to ppad-bolt1 0.1 and redesign the public API

Expose a single public module, Lightning.Protocol.BOLT4, built on the
types and TLV machinery of ppad-bolt1 and ppad-bolt9:

- secret keys (SecretKey, exactly 32 bytes in [1, n - 1]) and shared
  secrets are abstract, with redacted Show and constant-time Eq; derived
  keys and blinding factors are internal, so keystream generation can't
  be fed a bad key
- points are bolt1 Points at the API boundary; amounts are bolt1
  MilliSatoshis; allowed_features is a bolt9 FeatureVector
- the private BigSize, TLV, truncated-integer and SCID codecs are gone;
  HopPayload and BlindedHopData decode with bolt1's TLV stream, reject
  unknown even types and keep unknown odd ones, and payment_metadata
  and total_amount_msat are typed fields
- process takes the path_key received with an onion, derives the
  blinded private key, decrypts encrypted_recipient_data (with path_key
  or current_path_key) and returns its contents and the next path key;
  the HMAC check is constant time
- failures are a typed sum of every failure code in the spec (adding
  required_channel_feature_missing, unknown_next_peer and
  invalid_onion_blinding, dropping invalid_realm), with an escape hatch
  for unknown codes and derivable flags
- unwrap_error reports the hop whose HMAC matches even when its failure
  message is malformed
- NFData instances for every public type

Diffstat:
Mlib/Lightning/Protocol/BOLT4.hs | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mlib/Lightning/Protocol/BOLT4/Blinding.hs | 503+++++++++++++++++++------------------------------------------------------------
Mlib/Lightning/Protocol/BOLT4/Codec.hs | 743+++++++++++++++++++++++++++++++++++--------------------------------------------
Mlib/Lightning/Protocol/BOLT4/Construct.hs | 320+++++++++++++++++++++++++++++++++----------------------------------------------
Mlib/Lightning/Protocol/BOLT4/Error.hs | 304++++++++++++++++++++++++++-----------------------------------------------------
Dlib/Lightning/Protocol/BOLT4/Internal.hs | 40----------------------------------------
Mlib/Lightning/Protocol/BOLT4/Prim.hs | 424++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mlib/Lightning/Protocol/BOLT4/Process.hs | 356+++++++++++++++++++++++++++++++------------------------------------------------
Mlib/Lightning/Protocol/BOLT4/Types.hs | 714++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mppad-bolt4.cabal | 58++++++++++++++++++++++++++++++++++------------------------
10 files changed, 1639 insertions(+), 2007 deletions(-)

diff --git a/lib/Lightning/Protocol/BOLT4.hs b/lib/Lightning/Protocol/BOLT4.hs @@ -1,5 +1,4 @@ {-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE PatternSynonyms #-} -- | -- Module: Lightning.Protocol.BOLT4 @@ -7,84 +6,147 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- BOLT4 onion routing for the Lightning Network. +-- Onion routing for the Lightning Network, per BOLT #4 +-- (<https://github.com/lightning/bolts/blob/master/04-onion-routing.md>): +-- onion construction and processing, returning errors, and route +-- blinding. -- --- This module re-exports the public interface from submodules. +-- The examples below assume: +-- +-- >>> :set -XOverloadedStrings +-- >>> import qualified Data.ByteString as BS +-- >>> import qualified Lightning.Protocol.BOLT1 as BOLT1 +-- >>> import Lightning.Protocol.BOLT4 +-- +-- Construct a two-hop onion: +-- +-- >>> let Just session = secret_key (BS.replicate 32 0x41) +-- >>> let Just alice = secret_key (BS.replicate 32 0x42) +-- >>> let Just bob = secret_key (BS.replicate 32 0x43) +-- >>> let Just scid = BOLT1.short_channel_id 800000 1 0 +-- >>> let Just amt = BOLT1.milli_satoshi 100000 +-- >>> :{ +-- let alice_payload = empty_hop_payload { +-- hp_amt_to_forward = Just amt +-- , hp_outgoing_cltv_value = Just 800040 +-- , hp_short_channel_id = Just scid +-- } +-- bob_payload = empty_hop_payload { +-- hp_amt_to_forward = Just amt +-- , hp_outgoing_cltv_value = Just 800000 +-- } +-- route = [ Hop (public_key alice) alice_payload +-- , Hop (public_key bob) bob_payload ] +-- payment_hash = BS.replicate 32 0xff +-- :} +-- >>> let Right (onion, secrets) = construct session route payment_hash +-- +-- Process it at each hop: +-- +-- >>> let Right (Forward fwd) = process alice onion payment_hash Nothing +-- >>> hp_short_channel_id (fwd_payload fwd) == Just scid +-- True +-- >>> let next = fwd_next_packet fwd +-- >>> let Right (Receive rcv) = process bob next payment_hash Nothing +-- >>> hp_outgoing_cltv_value (rcv_payload rcv) +-- Just 800000 +-- +-- Fail at the final hop, wrap the error at the first, and attribute it +-- at the origin: +-- +-- >>> let tlvs = BOLT1.empty_tlv_stream +-- >>> let failure = FailureMessage TemporaryNodeFailure tlvs +-- >>> let Right err = construct_error (rcv_shared_secret rcv) failure +-- >>> let returned = wrap_error (fwd_shared_secret fwd) err +-- >>> unwrap_error secrets returned == Attributed 1 failure +-- True module Lightning.Protocol.BOLT4 ( - -- * Re-exports - module Lightning.Protocol.BOLT4.Blinding - , module Lightning.Protocol.BOLT4.Codec - , module Lightning.Protocol.BOLT4.Prim + -- * Keys + SecretKey + , secret_key + , public_key + , SharedSecret + , shared_secret + , un_shared_secret - -- * Fixed-size newtypes + -- * Onion packets + , OnionPacket(..) + , HopPayloads + , hop_payloads + , un_hop_payloads , Hmac32 , hmac32 - , unHmac32 - , HopPayloads - , hopPayloads - , unHopPayloads - , PaymentSecret - , paymentSecret - , unPaymentSecret + , un_hmac32 + , encode_onion_packet + , decode_onion_packet - -- * Packet types - , OnionPacket(..) + -- * Hop payloads , HopPayload(..) - , ShortChannelId(..) - , shortChannelId - , scidBlockHeight - , scidTxIndex - , scidOutputIndex - , scidWord64 + , empty_hop_payload , PaymentData(..) - , TlvRecord(..) + , PaymentSecret + , payment_secret + , un_payment_secret + , encode_hop_payload + , decode_hop_payload - -- * Error types - , FailureMessage(..) - , FailureCode(..) - -- ** Flag bits - , pattern BADONION - , pattern PERM - , pattern NODE - , pattern UPDATE - -- ** Common failure codes - , pattern InvalidRealm - , pattern TemporaryNodeFailure - , pattern PermanentNodeFailure - , pattern RequiredNodeFeatureMissing - , pattern InvalidOnionVersion - , pattern InvalidOnionHmac - , pattern InvalidOnionKey - , pattern TemporaryChannelFailure - , pattern PermanentChannelFailure - , pattern AmountBelowMinimum - , pattern FeeInsufficient - , pattern IncorrectCltvExpiry - , pattern ExpiryTooSoon - , pattern IncorrectOrUnknownPaymentDetails - , pattern FinalIncorrectCltvExpiry - , pattern FinalIncorrectHtlcAmount - , pattern ChannelDisabled - , pattern ExpiryTooFar - , pattern InvalidOnionPayload - , pattern MppTimeout + -- * Constructing onions + , Hop(..) + , construct + , ConstructError(..) - -- * Processing results + -- * Processing onions + , process , ProcessResult(..) , ForwardInfo(..) , ReceiveInfo(..) + , BlindedInfo(..) + , ProcessError(..) + + -- * Route blinding + , BlindedPath(..) + , BlindedHop(..) + , create_blinded_path + , BlindingError(..) + , decrypt_recipient_data + , BlindedHopData(..) + , empty_blinded_hop_data + , PaymentRelay(..) + , PaymentConstraints(..) + , encode_blinded_hop_data + , decode_blinded_hop_data + + -- * Failure messages + , FailureMessage(..) + , Failure(..) + , OnionHash + , onion_hash + , un_onion_hash + , failure_code + , is_badonion + , is_perm + , is_node + , is_update + , encode_failure_message + , decode_failure_message + + -- * Returning errors + , ErrorPacket(..) + , construct_error + , wrap_error + , unwrap_error + , Attribution(..) - -- * Constants - , onionPacketSize - , hopPayloadsSize - , hmacSize - , pubkeySize - , versionByte - , maxPayloadSize + -- * Encoding errors + , DecodeError(..) + , EncodeError(..) ) where import Lightning.Protocol.BOLT4.Blinding import Lightning.Protocol.BOLT4.Codec +import Lightning.Protocol.BOLT4.Construct +import Lightning.Protocol.BOLT4.Error import Lightning.Protocol.BOLT4.Prim +import Lightning.Protocol.BOLT4.Process import Lightning.Protocol.BOLT4.Types diff --git a/lib/Lightning/Protocol/BOLT4/Blinding.hs b/lib/Lightning/Protocol/BOLT4/Blinding.hs @@ -1,6 +1,5 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} -{-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_HADDOCK hide #-} +{-# LANGUAGE DeriveGeneric #-} -- | -- Module: Lightning.Protocol.BOLT4.Blinding @@ -8,395 +7,137 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Route blinding for BOLT4 onion routing. +-- Route blinding. 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 + BlindingError(..) + , create_blinded_path + , decrypt_recipient_data + , unblind ) where +import Control.DeepSeq (NFData) 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 GHC.Generics (Generic) +import qualified Lightning.Protocol.BOLT1 as BOLT1 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) +import Lightning.Protocol.BOLT4.Prim +import Lightning.Protocol.BOLT4.Types --- | 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. +-- | Why a blinded route could not be created. data BlindingError - = InvalidSeed - | EmptyPath - | InvalidNodeKey Int - | DecryptionFailed - | InvalidPathKey - deriving (Eq, Show) - --- Key derivation ------------------------------------------------------------ - --- | Derive rho key for encrypting hop data. + = EmptyPath + -- ^ the route has no hops + | InvalidNodeId {-# UNPACK #-} !Int + -- ^ the node id of the hop with this index is not a valid point + | InvalidHopData {-# UNPACK #-} !Int !EncodeError + -- ^ the data of the hop with this index can't be encoded + deriving (Eq, Show, Generic) + +instance NFData BlindingError + +-- | Create a blinded route to oneself, given a seed for the route's +-- ephemeral keys (which must be fresh and random for every route) and +-- each node's id and data, from the introduction node to oneself. -- --- @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@ --- --- Returns 'Nothing' if the secret key is not 32 bytes. -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) - | BS.length secKey /= 32 = Nothing - | otherwise = 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. --- --- Returns 'Nothing' if encryption fails (e.g. the key is not 32 --- bytes). -encryptHopData :: DerivedKey -> BlindedHopData -> Maybe 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 _ -> Nothing - Right (!ciphertext, !mac) -> Just (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. --- --- Fails on unknown even TLV types; unknown odd types are skipped. -decodeBlindedHopData :: BS.ByteString -> Maybe BlindedHopData -decodeBlindedHopData !bs = do - tlvs <- decodeTlvStream bs - parseBlindedHopData tlvs - -parseBlindedHopData :: [TlvRecord] -> Maybe BlindedHopData -parseBlindedHopData = go emptyHopData +-- >>> let Just seed = secret_key (BS.replicate 32 0x01) +-- >>> let Just me = secret_key (BS.replicate 32 0x45) +-- >>> let dat = empty_blinded_hop_data { bhd_path_id = Just "deadbeef" } +-- >>> let Right path = create_blinded_path seed [(public_key me, dat)] +-- >>> bp_first_node_id path == public_key me +-- True +-- >>> length (bp_hops path) +-- 1 +create_blinded_path + :: SecretKey + -> [(BOLT1.Point, BlindedHopData)] + -> Either BlindingError BlindedPath +create_blinded_path seed nodes = case nodes of + [] -> Left EmptyPath + (intro, _) : _ -> do + hops <- go (sk_bytes seed) (sk_pub seed) (zip [0 ..] nodes) + pure (BlindedPath intro (public_key seed) hops) 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 - _ | even typ -> Nothing - | otherwise -> go bhd rest - --- 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. + go _ _ [] = Right [] + go e epub ((i, (nid, dat)) : rest) = do + let note = maybe (Left (InvalidNodeId i)) Right + node <- note (from_point nid) + ss <- note (ecdh e node) + bid <- note (blind_pub node (blinded_node_tweak ss) >>= to_point) + plain <- either (Left . InvalidHopData i) Right + (encode_blinded_hop_data dat) + enc <- maybe (Left (InvalidHopData i FieldTooLong)) Right + (encrypt (derive_rho ss) plain) + let hop = BlindedHop bid enc + case rest of + [] -> Right [hop] + _ -> do + let bf = blinding_factor epub ss + e' <- note (blind_scalar e bf) + epub' <- note (blind_pub epub bf) + (hop :) <$> go e' epub' rest + +-- | Decrypt the @encrypted_recipient_data@ received by a node in a +-- blinded route, given the node's private key and the path key it +-- received. -- --- 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) - encData <- maybe (Left (InvalidNodeKey idx)) Right - (encryptHopData rho hopData) - let !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. +-- Returns the decrypted data and the path key for the next node (the +-- data's @next_path_key_override@, if present). +-- 'Lightning.Protocol.BOLT4.process' does this for payment onions. -- --- 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 #-} +-- >>> let Just seed = secret_key (BS.replicate 32 0x01) +-- >>> let Just me = secret_key (BS.replicate 32 0x45) +-- >>> let dat = empty_blinded_hop_data { bhd_path_id = Just "deadbeef" } +-- >>> let Right path = create_blinded_path seed [(public_key me, dat)] +-- >>> let [hop] = bp_hops path +-- >>> let pk = bp_first_path_key path +-- >>> let Right info = decrypt_recipient_data me pk (bh_encrypted_data hop) +-- >>> bhd_path_id (bi_data info) +-- Just "deadbeef" +decrypt_recipient_data + :: SecretKey + -> BOLT1.Point + -> BS.ByteString + -> Either ProcessError BlindedInfo +decrypt_recipient_data sk pk enc = do + e <- maybe (Left InvalidPathKey) Right (from_point pk) + ss <- maybe (Left InvalidPathKey) Right (ecdh (sk_bytes sk) e) + unblind ss e enc + +-- Decrypt encrypted_recipient_data, given the shared secret with the path +-- key, and the path key. +unblind + :: SharedSecret + -> Secp256k1.Projective + -> BS.ByteString + -> Either ProcessError BlindedInfo +unblind ss e enc = do + plain <- maybe (Left InvalidRecipientData) Right + (decrypt (derive_rho ss) enc) + dat <- either (const (Left InvalidRecipientData)) Right + (decode_blinded_hop_data plain) + next <- case bhd_next_path_key_override dat of + Just o -> Right o + Nothing -> maybe (Left InvalidPathKey) Right + (blind_pub e (blinding_factor e ss) >>= to_point) + pure (BlindedInfo dat next) + +-- ChaCha20-Poly1305 with an all-zero nonce and no associated data; the +-- tag follows the ciphertext. Fails only on counter overflow. +encrypt :: DerivedKey -> BS.ByteString -> Maybe BS.ByteString +encrypt k plain = + case AEAD.encrypt BS.empty (un_derived_key k) (BS.replicate 12 0) plain of + Right (c, t) -> Just (c <> t) + Left _ -> Nothing + +decrypt :: DerivedKey -> BS.ByteString -> Maybe BS.ByteString +decrypt k enc + | BS.length enc < 16 = Nothing + | otherwise = + let (c, t) = BS.splitAt (BS.length enc - 16) enc + in case AEAD.decrypt BS.empty (un_derived_key k) (BS.replicate 12 0) + (c, t) of + Right p -> Just p + Left _ -> Nothing diff --git a/lib/Lightning/Protocol/BOLT4/Codec.hs b/lib/Lightning/Protocol/BOLT4/Codec.hs @@ -1,6 +1,4 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} -{-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_HADDOCK hide #-} -- | -- Module: Lightning.Protocol.BOLT4.Codec @@ -8,433 +6,346 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Serialization and deserialization for BOLT4 types. +-- Encodings of the BOLT #4 types. module Lightning.Protocol.BOLT4.Codec ( - -- * BigSize encoding - encodeBigSize - , decodeBigSize - , bigSizeLen - - -- * TLV encoding - , encodeTlv - , decodeTlv - , decodeTlvStream - , encodeTlvStream - - -- * Packet serialization - , encodeOnionPacket - , decodeOnionPacket - , encodeHopPayload - , decodeHopPayload - - -- * ShortChannelId - , encodeShortChannelId - , decodeShortChannelId - - -- * Failure messages - , encodeFailureMessage - , decodeFailureMessage - - -- * Internal helpers (for Blinding) - , toStrict - , word16BE - , word32BE - , encodeWord64TU - , decodeWord64TU - , encodeWord32TU - , decodeWord32TU + encode_onion_packet + , decode_onion_packet + , encode_hop_payload + , decode_hop_payload + , encode_blinded_hop_data + , decode_blinded_hop_data + , encode_failure_message + , decode_failure_message ) where -import Data.Bits (shiftL) +import Data.Bifunctor (first) 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 Data.Maybe (catMaybes) +import Data.Word (Word16, Word64) +import qualified Lightning.Protocol.BOLT1 as BOLT1 import Lightning.Protocol.BOLT4.Types +import qualified Lightning.Protocol.BOLT9 as BOLT9 --- BigSize encoding --------------------------------------------------------- +-- onion packets -------------------------------------------------------------- --- | Encode integer as BigSize. +-- | Encode an 'OnionPacket' (1366 bytes). -- --- * 0-0xFC: 1 byte --- * 0xFD-0xFFFF: 0xFD ++ 2 bytes BE --- * 0x10000-0xFFFFFFFF: 0xFE ++ 4 bytes BE --- * larger: 0xFF ++ 8 bytes BE -encodeBigSize :: Word64 -> BS.ByteString -encodeBigSize !n - | n < 0xFD = BS.singleton (fromIntegral n) - | n <= 0xFFFF = toStrict $ - B.word8 0xFD <> B.word16BE (fromIntegral n) - | n <= 0xFFFFFFFF = toStrict $ - B.word8 0xFE <> B.word32BE (fromIntegral n) - | otherwise = toStrict $ - B.word8 0xFF <> B.word64BE n -{-# INLINE encodeBigSize #-} - --- | Decode BigSize, returning (value, remaining bytes). -decodeBigSize :: BS.ByteString -> Maybe (Word64, BS.ByteString) -decodeBigSize !bs = case BS.uncons bs of - Nothing -> Nothing - Just (b, rest) - | b < 0xFD -> Just (fromIntegral b, rest) - | b == 0xFD -> do - (hi, r1) <- BS.uncons rest - (lo, r2) <- BS.uncons r1 - let !val = fromIntegral hi `shiftL` 8 + fromIntegral lo - -- Canonical: must be >= 0xFD - if val < 0xFD then Nothing else Just (val, r2) - | b == 0xFE -> do - if BS.length rest < 4 then Nothing else do - let !bytes = BS.take 4 rest - !r = BS.drop 4 rest - !val = word32BE bytes - -- Canonical: must be > 0xFFFF - if val <= 0xFFFF then Nothing else Just (fromIntegral val, r) - | otherwise -> do -- b == 0xFF - if BS.length rest < 8 then Nothing else do - let !bytes = BS.take 8 rest - !r = BS.drop 8 rest - !val = word64BE bytes - -- Canonical: must be > 0xFFFFFFFF - if val <= 0xFFFFFFFF then Nothing else Just (val, r) -{-# INLINE decodeBigSize #-} - --- | Get encoded size of a BigSize value without encoding. -bigSizeLen :: Word64 -> Int -bigSizeLen !n - | n < 0xFD = 1 - | n <= 0xFFFF = 3 - | n <= 0xFFFFFFFF = 5 - | otherwise = 9 -{-# INLINE bigSizeLen #-} - --- TLV encoding ------------------------------------------------------------- - --- | Encode a TLV record. -encodeTlv :: TlvRecord -> BS.ByteString -encodeTlv (TlvRecord !typ !val) = toStrict $ - B.byteString (encodeBigSize typ) <> - B.byteString (encodeBigSize (fromIntegral (BS.length val))) <> - B.byteString val -{-# INLINE encodeTlv #-} - --- | Decode a single TLV record. -decodeTlv :: BS.ByteString -> Maybe (TlvRecord, BS.ByteString) -decodeTlv !bs = do - (typ, r1) <- decodeBigSize bs - (len, r2) <- decodeBigSize r1 - -- compare in Word64 so that large lengths cannot wrap - if len > fromIntegral (BS.length r2) - then Nothing - else do - let !len' = fromIntegral len - !val = BS.take len' r2 - !rest = BS.drop len' r2 - Just (TlvRecord typ val, rest) -{-# INLINE decodeTlv #-} - --- | Decode a TLV stream (sequence of records). --- Validates strictly increasing type order. -decodeTlvStream :: BS.ByteString -> Maybe [TlvRecord] -decodeTlvStream = go Nothing - where - go :: Maybe Word64 -> BS.ByteString -> Maybe [TlvRecord] - go _ !bs | BS.null bs = Just [] - go !mPrev !bs = do - (rec@(TlvRecord typ _), rest) <- decodeTlv bs - -- Check strictly increasing order - case mPrev of - Just prev | typ <= prev -> Nothing - _ -> do - recs <- go (Just typ) rest - Just (rec : recs) - --- | Encode a TLV stream from records. --- Records must be sorted by type, no duplicates. -encodeTlvStream :: [TlvRecord] -> BS.ByteString -encodeTlvStream !recs = toStrict $ foldMap (B.byteString . encodeTlv) recs -{-# INLINE encodeTlvStream #-} - --- Packet serialization ----------------------------------------------------- - --- | Serialize OnionPacket to 1366 bytes. -encodeOnionPacket :: OnionPacket -> BS.ByteString -encodeOnionPacket (OnionPacket !ver !eph !payloads !mac) = - toStrict $ - B.word8 ver <> - B.byteString eph <> - B.byteString (unHopPayloads payloads) <> - B.byteString (unHmac32 mac) -{-# INLINE encodeOnionPacket #-} - --- | Parse OnionPacket from 1366 bytes. -decodeOnionPacket :: BS.ByteString -> Maybe OnionPacket -decodeOnionPacket !bs - | BS.length bs /= onionPacketSize = Nothing - | otherwise = do - let !ver = BS.index bs 0 - !eph = BS.take pubkeySize (BS.drop 1 bs) - !payloadsRaw = BS.take hopPayloadsSize - (BS.drop (1 + pubkeySize) bs) - !macRaw = BS.drop - (1 + pubkeySize + hopPayloadsSize) bs - hp <- hopPayloads payloadsRaw - hm <- hmac32 macRaw - Just (OnionPacket ver eph hp hm) -{-# INLINE decodeOnionPacket #-} - --- | Encode HopPayload to bytes (without length prefix). -encodeHopPayload :: HopPayload -> BS.ByteString -encodeHopPayload !hp = encodeTlvStream (buildTlvs hp) +-- >>> let Just pk = BOLT1.point (BS.cons 0x02 (BS.replicate 32 0x01)) +-- >>> let Just hp = hop_payloads (BS.replicate 1300 0) +-- >>> let Just mac = hmac32 (BS.replicate 32 0) +-- >>> BS.length (encode_onion_packet (OnionPacket 0 pk hp mac)) +-- 1366 +encode_onion_packet :: OnionPacket -> BS.ByteString +encode_onion_packet (OnionPacket v k hp mac) = BS.concat + [BS.singleton v, BOLT1.un_point k, un_hop_payloads hp, un_hmac32 mac] + +-- | Decode an 'OnionPacket' from exactly 1366 bytes. The public key must +-- carry a compressed-encoding prefix; the version byte is not checked +-- (see 'Lightning.Protocol.BOLT4.process'). +-- +-- >>> decode_onion_packet (BS.replicate 1365 0) +-- Left InvalidLength +decode_onion_packet :: BS.ByteString -> Either DecodeError OnionPacket +decode_onion_packet bs = case BS.uncons bs of + Just (v, rest) | BS.length bs == 1366 -> do + let (k, r0) = BS.splitAt 33 rest + (hp, mac) = BS.splitAt 1300 r0 + pk <- maybe (Left InvalidPoint) Right (BOLT1.point k) + pure (OnionPacket v pk (HopPayloads hp) (Hmac32 mac)) + _ -> Left InvalidLength + +-- TLV helpers ---------------------------------------------------------------- + +-- encode typed records with extra ones, none of which may have a known +-- type +encode_tlvs + :: [Word64] + -> [BOLT1.TlvRecord] + -> BOLT1.TlvStream + -> Either EncodeError BS.ByteString +encode_tlvs known typed extra + | any ((`elem` known) . BOLT1.tlv_type) ex = Left ConflictingTlv + | otherwise = case BOLT1.tlv_stream (typed ++ ex) of + Nothing -> Left ConflictingTlv + Just s -> Right (BOLT1.encode_tlv_stream s) where - buildTlvs :: HopPayload -> [TlvRecord] - buildTlvs (HopPayload amt cltv sci pd ed cpk unk) = - let amt' = maybe [] (\a -> [TlvRecord 2 (encodeWord64TU a)]) amt - cltv' = maybe [] (\c -> [TlvRecord 4 (encodeWord32TU c)]) cltv - sci' = maybe [] (\s -> [TlvRecord 6 (encodeShortChannelId s)]) sci - pd' = maybe [] (\p -> [TlvRecord 8 (encodePaymentData p)]) pd - ed' = maybe [] (\e -> [TlvRecord 10 e]) ed - cpk' = maybe [] (\k -> [TlvRecord 12 k]) cpk - in amt' ++ cltv' ++ sci' ++ pd' ++ ed' ++ cpk' ++ unk - --- | Decode HopPayload from bytes. + ex = BOLT1.un_tlv_stream extra + +record :: Word64 -> (a -> BS.ByteString) -> Maybe a -> Maybe BOLT1.TlvRecord +record t f = fmap (BOLT1.TlvRecord t . f) + +-- decode a TLV stream with the given known types +decode_tlvs :: [Word64] -> BS.ByteString -> Either DecodeError BOLT1.TlvStream +decode_tlvs known = + first InvalidTlvStream . BOLT1.decode_tlv_stream (`elem` known) + +-- parse the value of a record, if present +field + :: Word64 + -> (BS.ByteString -> Maybe a) + -> BOLT1.TlvStream + -> Either DecodeError (Maybe a) +field t p s = case BOLT1.lookup_tlv t s of + Nothing -> Right Nothing + Just v -> maybe (Left (InvalidTlvValue t)) (Right . Just) (p v) + +-- the unknown records of a stream +extra_tlvs :: [Word64] -> BOLT1.TlvStream -> BOLT1.TlvStream +extra_tlvs known = BOLT1.filter_tlv_stream (`notElem` known) + +-- a decoder that must consume its whole input +whole + :: (BS.ByteString -> Maybe (a, BS.ByteString)) + -> BS.ByteString + -> Maybe a +whole d bs = case d bs of + Just (a, r) | BS.null r -> Just a + _ -> Nothing + +encode_tu_msat :: BOLT1.MilliSatoshi -> BS.ByteString +encode_tu_msat = BOLT1.encode_tu64 . BOLT1.un_milli_satoshi + +decode_tu_msat :: BS.ByteString -> Maybe BOLT1.MilliSatoshi +decode_tu_msat v = BOLT1.decode_tu64 v >>= BOLT1.milli_satoshi + +-- hop payloads --------------------------------------------------------------- + +hop_payload_types :: [Word64] +hop_payload_types = [2, 4, 6, 8, 10, 12, 16, 18] + +-- | Encode a 'HopPayload' as a @payload@ TLV stream (without its length +-- prefix). Fails if 'hp_extra' has a record of a known type. -- --- Fails on unknown even TLV types. Types 16 (payment_metadata) and 18 --- (total_amount_msat) are accepted and kept, with unknown odd types, --- in 'hpUnknownTlvs'. -decodeHopPayload :: BS.ByteString -> Maybe HopPayload -decodeHopPayload !bs = do - tlvs <- decodeTlvStream bs - parseHopPayload tlvs - -parseHopPayload :: [TlvRecord] -> Maybe HopPayload -parseHopPayload = go emptyHop +-- >>> let hp = empty_hop_payload { hp_outgoing_cltv_value = Just 144 } +-- >>> encode_hop_payload hp +-- Right "\EOT\SOH\144" +encode_hop_payload :: HopPayload -> Either EncodeError BS.ByteString +encode_hop_payload hp = encode_tlvs hop_payload_types typed (hp_extra hp) where - emptyHop :: HopPayload - emptyHop = HopPayload Nothing Nothing Nothing Nothing Nothing Nothing [] - - go :: HopPayload -> [TlvRecord] -> Maybe HopPayload - go !hp [] = Just hp { hpUnknownTlvs = reverse (hpUnknownTlvs hp) } - go !hp (TlvRecord typ val : rest) = case typ of - 2 -> do - amt <- decodeWord64TU val - go hp { hpAmtToForward = Just amt } rest - 4 -> do - cltv <- decodeWord32TU val - go hp { hpOutgoingCltv = Just cltv } rest - 6 -> do - sci <- decodeShortChannelId val - go hp { hpShortChannelId = Just sci } rest - 8 -> do - pd <- decodePaymentData val - go hp { hpPaymentData = Just pd } rest - 10 -> go hp { hpEncryptedData = Just val } rest - 12 -> go hp { hpCurrentPathKey = Just val } rest - _ | even typ && typ /= 16 && typ /= 18 -> Nothing - | otherwise -> - go hp { hpUnknownTlvs = TlvRecord typ val : hpUnknownTlvs hp } - rest - --- ShortChannelId ----------------------------------------------------------- - --- | Encode ShortChannelId to 8 bytes big-endian. -encodeShortChannelId :: ShortChannelId -> BS.ByteString -encodeShortChannelId !sci = toStrict (B.word64BE (scidWord64 sci)) -{-# INLINE encodeShortChannelId #-} - --- | Decode ShortChannelId from 8 bytes big-endian. -decodeShortChannelId :: BS.ByteString -> Maybe ShortChannelId -decodeShortChannelId !bs - | BS.length bs /= 8 = Nothing - | otherwise = - let !w = (fromIntegral (BS.index bs 0) `shiftL` 56) - + (fromIntegral (BS.index bs 1) `shiftL` 48) - + (fromIntegral (BS.index bs 2) `shiftL` 40) - + (fromIntegral (BS.index bs 3) `shiftL` 32) - + (fromIntegral (BS.index bs 4) `shiftL` 24) - + (fromIntegral (BS.index bs 5) `shiftL` 16) - + (fromIntegral (BS.index bs 6) `shiftL` 8) - + fromIntegral (BS.index bs 7) :: Word64 - in Just (ShortChannelId w) -{-# INLINE decodeShortChannelId #-} - --- Failure messages --------------------------------------------------------- - --- | Encode failure message. + typed = catMaybes + [ record 2 encode_tu_msat (hp_amt_to_forward hp) + , record 4 BOLT1.encode_tu32 (hp_outgoing_cltv_value hp) + , record 6 BOLT1.encode_short_channel_id (hp_short_channel_id hp) + , record 8 encode_payment_data (hp_payment_data hp) + , record 10 id (hp_encrypted_data hp) + , record 12 BOLT1.un_point (hp_current_path_key hp) + , record 16 id (hp_payment_metadata hp) + , record 18 encode_tu_msat (hp_total_amount_msat hp) + ] + +-- | Decode a @payload@ TLV stream (without its length prefix). Fails on +-- a malformed stream, an unknown even type, or a malformed known +-- record. -- --- 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.byteString dat <> - B.byteString (encodeTlvStream tlvs) -{-# INLINE encodeFailureMessage #-} - --- | Decode failure message. +-- >>> fmap hp_outgoing_cltv_value (decode_hop_payload "\EOT\SOH\144") +-- Right (Just 144) +-- >>> decode_hop_payload "\DC4\NUL" +-- Left (InvalidTlvStream (TlvUnknownEvenType 20)) +decode_hop_payload :: BS.ByteString -> Either DecodeError HopPayload +decode_hop_payload bs = do + s <- decode_tlvs hop_payload_types bs + HopPayload + <$> field 2 decode_tu_msat s + <*> field 4 BOLT1.decode_tu32 s + <*> field 6 (whole BOLT1.decode_short_channel_id) s + <*> field 8 decode_payment_data s + <*> field 10 Just s + <*> field 12 BOLT1.point s + <*> field 16 Just s + <*> field 18 decode_tu_msat s + <*> pure (extra_tlvs hop_payload_types s) + +encode_payment_data :: PaymentData -> BS.ByteString +encode_payment_data (PaymentData s t) = un_payment_secret s <> encode_tu_msat t + +decode_payment_data :: BS.ByteString -> Maybe PaymentData +decode_payment_data v = do + let (s, t) = BS.splitAt 32 v + PaymentData <$> payment_secret s <*> decode_tu_msat t + +-- blinded hop data ----------------------------------------------------------- + +blinded_hop_data_types :: [Word64] +blinded_hop_data_types = [1, 2, 4, 6, 8, 10, 12, 14] + +-- | Encode 'BlindedHopData' as an @encrypted_data_tlv@ stream. Fails if +-- 'bhd_extra' has a record of a known type. +encode_blinded_hop_data :: BlindedHopData -> Either EncodeError BS.ByteString +encode_blinded_hop_data d = + encode_tlvs blinded_hop_data_types typed (bhd_extra d) + where + typed = catMaybes + [ record 1 id (bhd_padding d) + , record 2 BOLT1.encode_short_channel_id (bhd_short_channel_id d) + , record 4 BOLT1.un_point (bhd_next_node_id d) + , record 6 id (bhd_path_id d) + , record 8 BOLT1.un_point (bhd_next_path_key_override d) + , record 10 encode_payment_relay (bhd_payment_relay d) + , record 12 encode_payment_constraints (bhd_payment_constraints d) + , record 14 BOLT9.render (bhd_allowed_features d) + ] + +-- | Decode an @encrypted_data_tlv@ stream. Fails on a malformed stream, +-- an unknown even type, or a malformed known record. -- --- 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. +-- >>> fmap bhd_path_id (decode_blinded_hop_data "\ACK\apath id") +-- Right (Just "path id") +decode_blinded_hop_data :: BS.ByteString -> Either DecodeError BlindedHopData +decode_blinded_hop_data bs = do + s <- decode_tlvs blinded_hop_data_types bs + BlindedHopData + <$> field 1 Just s + <*> field 2 (whole BOLT1.decode_short_channel_id) s + <*> field 4 BOLT1.point s + <*> field 6 Just s + <*> field 8 BOLT1.point s + <*> field 10 decode_payment_relay s + <*> field 12 decode_payment_constraints s + <*> field 14 (Just . BOLT9.parse) s + <*> pure (extra_tlvs blinded_hop_data_types s) + +encode_payment_relay :: PaymentRelay -> BS.ByteString +encode_payment_relay (PaymentRelay c p b) = + BOLT1.encode_u16 c <> BOLT1.encode_u32 p <> BOLT1.encode_tu32 b + +decode_payment_relay :: BS.ByteString -> Maybe PaymentRelay +decode_payment_relay v = do + (c, r0) <- BOLT1.decode_u16 v + (p, r1) <- BOLT1.decode_u32 r0 + PaymentRelay c p <$> BOLT1.decode_tu32 r1 + +encode_payment_constraints :: PaymentConstraints -> BS.ByteString +encode_payment_constraints (PaymentConstraints c m) = + BOLT1.encode_u32 c <> encode_tu_msat m + +decode_payment_constraints :: BS.ByteString -> Maybe PaymentConstraints +decode_payment_constraints v = do + (c, r) <- BOLT1.decode_u32 v + PaymentConstraints c <$> decode_tu_msat r + +-- failure messages ----------------------------------------------------------- + +-- | Encode a 'FailureMessage': the failure code, its data and the TLV +-- stream. Fails if a @channel_update@ exceeds 65535 bytes. -- --- 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 - (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 --------------------------------------------------------- - --- | Convert Builder to strict ByteString. -toStrict :: B.Builder -> BS.ByteString -toStrict = BL.toStrict . B.toLazyByteString -{-# INLINE toStrict #-} - --- | 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 `shiftL` 8) + b1 -{-# INLINE word16BE #-} - --- | Decode big-endian Word32. -word32BE :: BS.ByteString -> Word32 -word32BE !bs = - let !b0 = fromIntegral (BS.index bs 0) :: Word32 - !b1 = fromIntegral (BS.index bs 1) :: Word32 - !b2 = fromIntegral (BS.index bs 2) :: Word32 - !b3 = fromIntegral (BS.index bs 3) :: Word32 - in (b0 `shiftL` 24) + (b1 `shiftL` 16) + (b2 `shiftL` 8) + b3 -{-# INLINE word32BE #-} - --- | Decode big-endian Word64. -word64BE :: BS.ByteString -> Word64 -word64BE !bs = - let !b0 = fromIntegral (BS.index bs 0) :: Word64 - !b1 = fromIntegral (BS.index bs 1) :: Word64 - !b2 = fromIntegral (BS.index bs 2) :: Word64 - !b3 = fromIntegral (BS.index bs 3) :: Word64 - !b4 = fromIntegral (BS.index bs 4) :: Word64 - !b5 = fromIntegral (BS.index bs 5) :: Word64 - !b6 = fromIntegral (BS.index bs 6) :: Word64 - !b7 = fromIntegral (BS.index bs 7) :: Word64 - in (b0 `shiftL` 56) + (b1 `shiftL` 48) + (b2 `shiftL` 40) + - (b3 `shiftL` 32) + (b4 `shiftL` 24) + (b5 `shiftL` 16) + - (b6 `shiftL` 8) + b7 -{-# INLINE word64BE #-} - --- | Encode Word64 as truncated unsigned (minimal bytes). -encodeWord64TU :: Word64 -> BS.ByteString -encodeWord64TU !n - | n == 0 = BS.empty - | otherwise = BS.dropWhile (== 0) (toStrict (B.word64BE n)) -{-# INLINE encodeWord64TU #-} - --- | Decode truncated unsigned to Word64. -decodeWord64TU :: BS.ByteString -> Maybe Word64 -decodeWord64TU !bs - | BS.null bs = Just 0 - | BS.length bs > 8 = Nothing - | not (BS.null bs) && BS.index bs 0 == 0 = Nothing -- Non-canonical - | otherwise = Just (go 0 bs) +-- >>> let tlvs = BOLT1.empty_tlv_stream +-- >>> encode_failure_message (FailureMessage TemporaryNodeFailure tlvs) +-- Right " \STX" +encode_failure_message :: FailureMessage -> Either EncodeError BS.ByteString +encode_failure_message (FailureMessage f tlvs) = do + body <- encode_failure f + pure (BOLT1.encode_u16 (failure_code f) <> body + <> BOLT1.encode_tlv_stream tlvs) + +encode_failure :: Failure -> Either EncodeError BS.ByteString +encode_failure f = case f of + TemporaryNodeFailure -> Right mempty + PermanentNodeFailure -> Right mempty + RequiredNodeFeatureMissing -> Right mempty + InvalidOnionVersion h -> Right (un_onion_hash h) + InvalidOnionHmac h -> Right (un_onion_hash h) + InvalidOnionKey h -> Right (un_onion_hash h) + TemporaryChannelFailure u -> update u + PermanentChannelFailure -> Right mempty + RequiredChannelFeatureMissing -> Right mempty + UnknownNextPeer -> Right mempty + AmountBelowMinimum m u -> (msat m <>) <$> update u + FeeInsufficient m u -> (msat m <>) <$> update u + IncorrectCltvExpiry c u -> (BOLT1.encode_u32 c <>) <$> update u + ExpiryTooSoon u -> update u + IncorrectOrUnknownPaymentDetails m h -> Right (msat m <> BOLT1.encode_u32 h) + FinalIncorrectCltvExpiry c -> Right (BOLT1.encode_u32 c) + FinalIncorrectHtlcAmount m -> Right (msat m) + ChannelDisabled d u -> (BOLT1.encode_u16 d <>) <$> update u + ExpiryTooFar -> Right mempty + InvalidOnionPayload Nothing -> Right mempty + InvalidOnionPayload (Just (t, o)) -> + Right (BOLT1.encode_bigsize t <> BOLT1.encode_u16 o) + MppTimeout -> Right mempty + InvalidOnionBlinding h -> Right (un_onion_hash h) + UnknownFailure _ d -> Right d where - go :: Word64 -> BS.ByteString -> Word64 - go !acc !b = case BS.uncons b of - Nothing -> acc - Just (x, rest) -> go ((acc `shiftL` 8) + fromIntegral x) rest -{-# INLINE decodeWord64TU #-} - --- | Encode Word32 as truncated unsigned. -encodeWord32TU :: Word32 -> BS.ByteString -encodeWord32TU !n - | n == 0 = BS.empty - | otherwise = BS.dropWhile (== 0) (toStrict (B.word32BE n)) -{-# INLINE encodeWord32TU #-} - --- | Decode truncated unsigned to Word32. -decodeWord32TU :: BS.ByteString -> Maybe Word32 -decodeWord32TU !bs - | BS.null bs = Just 0 - | BS.length bs > 4 = Nothing - | not (BS.null bs) && BS.index bs 0 == 0 = Nothing -- Non-canonical - | otherwise = Just (go 0 bs) + msat = BOLT1.encode_milli_satoshi + update = maybe (Left FieldTooLong) Right . BOLT1.encode_u16_prefixed + +-- | Decode a failure message. +-- +-- Per BOLT #4, bytes following a failure's data are ignored unless +-- they form a valid TLV stream (which, as no failure TLV types are +-- defined, may hold only odd types). The data of an unknown failure +-- code is every byte following it. +-- +-- >>> fmap fm_failure (decode_failure_message " \STX") +-- Right TemporaryNodeFailure +-- >>> decode_failure_message "\DLE\r" +-- Left (InvalidFailureData 4109) +decode_failure_message :: BS.ByteString -> Either DecodeError FailureMessage +decode_failure_message bs = case BOLT1.decode_u16 bs of + Nothing -> Left InvalidLength + Just (c, rest) -> case decode_failure c rest of + Nothing -> Left (InvalidFailureData c) + Just (f, extra) -> + let tlvs = either (const BOLT1.empty_tlv_stream) id + (BOLT1.decode_tlv_stream (const False) extra) + in Right (FailureMessage f tlvs) + +decode_failure :: Word16 -> BS.ByteString -> Maybe (Failure, BS.ByteString) +decode_failure c bs = case c of + 0x2002 -> plain TemporaryNodeFailure + 0x6002 -> plain PermanentNodeFailure + 0x6003 -> plain RequiredNodeFeatureMissing + 0xc004 -> hashed InvalidOnionVersion + 0xc005 -> hashed InvalidOnionHmac + 0xc006 -> hashed InvalidOnionKey + 0x1007 -> do + (u, r) <- update bs + pure (TemporaryChannelFailure u, r) + 0x4008 -> plain PermanentChannelFailure + 0x4009 -> plain RequiredChannelFeatureMissing + 0x400a -> plain UnknownNextPeer + 0x100b -> do + (m, r0) <- BOLT1.decode_milli_satoshi bs + (u, r1) <- update r0 + pure (AmountBelowMinimum m u, r1) + 0x100c -> do + (m, r0) <- BOLT1.decode_milli_satoshi bs + (u, r1) <- update r0 + pure (FeeInsufficient m u, r1) + 0x100d -> do + (e, r0) <- BOLT1.decode_u32 bs + (u, r1) <- update r0 + pure (IncorrectCltvExpiry e u, r1) + 0x100e -> do + (u, r) <- update bs + pure (ExpiryTooSoon u, r) + 0x400f -> do + (m, r0) <- BOLT1.decode_milli_satoshi bs + (h, r1) <- BOLT1.decode_u32 r0 + pure (IncorrectOrUnknownPaymentDetails m h, r1) + 0x0012 -> do + (e, r) <- BOLT1.decode_u32 bs + pure (FinalIncorrectCltvExpiry e, r) + 0x0013 -> do + (m, r) <- BOLT1.decode_milli_satoshi bs + pure (FinalIncorrectHtlcAmount m, r) + 0x1014 -> do + (d, r0) <- BOLT1.decode_u16 bs + (u, r1) <- update r0 + pure (ChannelDisabled d u, r1) + 0x0015 -> plain ExpiryTooFar + 0x4016 -> Just $ case BOLT1.decode_bigsize bs of + Just (t, r0) | Just (o, r1) <- BOLT1.decode_u16 r0 -> + (InvalidOnionPayload (Just (t, o)), r1) + _ -> (InvalidOnionPayload Nothing, bs) + 0x0017 -> plain MppTimeout + 0xc018 -> hashed InvalidOnionBlinding + _ -> Just (UnknownFailure c bs, BS.empty) where - go :: Word32 -> BS.ByteString -> Word32 - go !acc !b = case BS.uncons b of - Nothing -> acc - Just (x, rest) -> go ((acc `shiftL` 8) + fromIntegral x) rest -{-# INLINE decodeWord32TU #-} - --- | Encode PaymentData. -encodePaymentData :: PaymentData -> BS.ByteString -encodePaymentData (PaymentData !secret !total) = - unPaymentSecret secret <> encodeWord64TU total -{-# INLINE encodePaymentData #-} - --- | Decode PaymentData. -decodePaymentData :: BS.ByteString -> Maybe PaymentData -decodePaymentData !bs - | BS.length bs < 32 = Nothing - | otherwise = do - ps <- paymentSecret (BS.take 32 bs) - let !rest = BS.drop 32 bs - total <- decodeWord64TU rest - Just (PaymentData ps total) -{-# INLINE decodePaymentData #-} + plain f = Just (f, bs) + hashed g + | BS.length bs < 32 = Nothing + | otherwise = + let (h, r) = BS.splitAt 32 bs + in Just (g (OnionHash h), r) + update = BOLT1.decode_u16_prefixed diff --git a/lib/Lightning/Protocol/BOLT4/Construct.hs b/lib/Lightning/Protocol/BOLT4/Construct.hs @@ -1,6 +1,6 @@ -{-# OPTIONS_HADDOCK prune #-} +{-# OPTIONS_HADDOCK hide #-} {-# LANGUAGE BangPatterns #-} -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DeriveGeneric #-} -- | -- Module: Lightning.Protocol.BOLT4.Construct @@ -8,221 +8,165 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Onion packet construction for BOLT4. +-- Onion packet construction. module Lightning.Protocol.BOLT4.Construct ( - -- * Types Hop(..) - , Error(..) - - -- * Packet construction + , ConstructError(..) , construct ) where -import Data.Bits (xor) +import Control.DeepSeq (NFData) import qualified Crypto.Curve.Secp256k1 as Secp256k1 import qualified Data.ByteString as BS -import Lightning.Protocol.BOLT4.Codec -import Lightning.Protocol.BOLT4.Internal +import qualified Data.List as L +import GHC.Generics (Generic) +import qualified Lightning.Protocol.BOLT1 as BOLT1 +import Lightning.Protocol.BOLT4.Codec (encode_hop_payload) import Lightning.Protocol.BOLT4.Prim import Lightning.Protocol.BOLT4.Types --- | Route information for a single hop. +-- | A hop of a route: the public key the hop's onion layer is encrypted +-- to (its node id, or its blinded node id in a blinded route), and its +-- payload. 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 - | InvalidHopPayload !Int -- ^ hop index of a payload under 2 bytes - deriving (Eq, Show) + { hop_pubkey :: !BOLT1.Point + , hop_payload :: !HopPayload + } deriving (Eq, Show, Generic) --- | Maximum number of hops in a route. -maxHops :: Int -maxHops = 20 -{-# INLINE maxHops #-} +instance NFData Hop --- | Construct an onion packet for a payment route. +-- | Why an onion packet could not be constructed. +data ConstructError + = EmptyRoute + -- ^ the route has no hops + | TooManyHops + -- ^ the route has more than 20 hops + | InvalidHopPubKey {-# UNPACK #-} !Int + -- ^ the public key of the hop with this index is not a valid point + | InvalidHopPayload {-# UNPACK #-} !Int + -- ^ the payload of the hop with this index can't be encoded, or + -- encodes to fewer than 2 bytes + | PayloadsTooLarge {-# UNPACK #-} !Int + -- ^ the hops' payloads, with their length prefixes and HMACs, take + -- this many bytes, more than the 1300 available + deriving (Eq, Show, Generic) + +instance NFData ConstructError + +-- | Construct an onion packet for a route, given a session key (which +-- must be fresh and random for every onion), the route's hops from +-- the first to the final one, and the associated data (the payment +-- hash, for a payment). -- --- Takes a session key (32 bytes random), list of hops, and associated --- data (typically payment_hash). +-- Returns the packet and the shared secret of each hop, in route +-- order; keep the latter to attribute returned errors with +-- 'Lightning.Protocol.BOLT4.unwrap_error'. -- --- Returns the onion packet and list of shared secrets (for error --- attribution). +-- >>> let Just session = secret_key (BS.replicate 32 0x41) +-- >>> let Just node = secret_key (BS.replicate 32 0x42) +-- >>> let Just amt = BOLT1.milli_satoshi 1000 +-- >>> :{ +-- let payload = empty_hop_payload { +-- hp_amt_to_forward = Just amt +-- , hp_outgoing_cltv_value = Just 800000 +-- } +-- route = [Hop (public_key node) payload] +-- :} +-- >>> let Right (pkt, sss) = construct session route (BS.replicate 32 0) +-- >>> BS.length (encode_onion_packet pkt) +-- 1366 +-- >>> length sss +-- 1 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 + :: SecretKey + -> [Hop] + -> BS.ByteString + -> Either ConstructError (OnionPacket, [SharedSecret]) +construct sk hops ad + | null hops = Left EmptyRoute + | length hops > 20 = 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 (receivers reject lengths 0 and 1) - let payloadBytes = map (encodeHopPayload . hopPayload) hops - payloadSizes = map payloadShiftSize payloadBytes - totalSize = sum payloadSizes - case [ i | (i, p) <- zip [0 ..] payloadBytes, BS.length p < 2 ] of - i : _ -> Left (InvalidHopPayload i) - [] -> pure () - if totalSize > hopPayloadsSize - then Left (PayloadTooLarge totalSize) + pubs <- traverse parse_pub ihops + pls <- traverse encode_pl ihops + let sizes = map shift_size pls + total = sum sizes + if total > 1300 + then Left (PayloadsTooLarge total) else do - -- Generate each hop's rho stream (2 * 1300 bytes) - rhoStreams <- traverse rhoStream (zip [0 ..] secrets) - - -- Generate filler using streams for all but final hop - let numHops = length hops - streamsExceptFinal = take (numHops - 1) rhoStreams - sizesExceptFinal = take (numHops - 1) payloadSizes - filler = generateFiller streamsExceptFinal sizesExceptFinal - - -- Initialize hop_payloads with deterministic padding - initialPayloads <- maybe (Left InvalidSessionKey) Right - (generateStream (derivePad (SharedSecret sessionKey)) - hopPayloadsSize) - - -- Wrap payloads in reverse order (final hop first) - let (finalPayloads, finalHmac) = wrapAllHops secrets - rhoStreams payloadBytes filler assocData initialPayloads + sss <- shared_secrets (sk_bytes sk) (sk_pub sk) (zip [0 ..] pubs) + let streams = map (\ss -> keystream (derive_rho ss) 2600) sss + n = length hops + fill = filler (take (n - 1) streams) (take (n - 1) sizes) + start = keystream (derive_pad sk) 1300 + (body, mac) = wrap ad fill start + (reverse (zip3 sss streams pls)) + pkt = OnionPacket 0 (public_key sk) (HopPayloads body) + (Hmac32 mac) + pure (pkt, sss) + where + ihops = zip [0 ..] hops - -- Build the final packet - let ephPubBytes = Secp256k1.serialize_point ephPub - packet = OnionPacket - { opVersion = versionByte - , opEphemeralKey = ephPubBytes - , opHopPayloads = unsafeHopPayloads finalPayloads - , opHmac = unsafeHmac32 finalHmac - } + parse_pub (i, h) = + maybe (Left (InvalidHopPubKey i)) Right (from_point (hop_pubkey h)) - Right (packet, secrets) - where - rhoStream (i, ss) = maybe (Left (InvalidHopPubKey i)) Right - (generateStream (deriveRho ss) (2 * hopPayloadsSize)) + encode_pl (i, h) = case encode_hop_payload (hop_payload h) of + Right p | BS.length p >= 2 -> Right p + _ -> Left (InvalidHopPayload i) --- | 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 #-} +-- bytes a payload takes in hop_payloads: length prefix, payload and HMAC +shift_size :: BS.ByteString -> Int +shift_size p = + let !l = BS.length p + in BS.length (BOLT1.encode_bigsize (fromIntegral l)) + l + 32 --- | Compute shared secrets for all hops. -computeAllSecrets +-- the shared secret of each hop, blinding the ephemeral key pair after +-- each one +shared_secrets :: BS.ByteString -> Secp256k1.Projective - -> [Secp256k1.Projective] - -> Either Error ([SharedSecret], Secp256k1.Projective) -computeAllSecrets !initSec !initPub = go initSec initPub 0 [] + -> [(Int, Secp256k1.Projective)] + -> Either ConstructError [SharedSecret] +shared_secrets _ _ [] = Right [] +shared_secrets e epub ((i, pub) : rest) = do + ss <- note (ecdh e pub) + case rest of + [] -> Right [ss] + _ -> do + let bf = blinding_factor epub ss + e' <- note (blind_scalar e bf) + epub' <- note (blind_pub epub bf) + (ss :) <$> shared_secrets e' epub' rest 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 + note = maybe (Left (InvalidHopPubKey i)) Right --- | Generate filler bytes from the hops' 2600-byte rho streams. --- --- At each hop the filler, extended by that hop's shift size, is XORed --- with the hop's rho stream starting at 1300 minus the length of the --- filler before extension. -generateFiller :: [BS.ByteString] -> [Int] -> BS.ByteString -generateFiller !streams !sizes = go BS.empty streams sizes +-- the filler, from the 2600-byte rho streams and shift sizes of all +-- hops but the final one +filler :: [BS.ByteString] -> [Int] -> BS.ByteString +filler streams sizes = L.foldl' step BS.empty (zip streams sizes) where - go !filler [] [] = filler - go !filler (stream:sts) (sz:szs) = - let !extended = filler <> BS.replicate sz 0 - !streamOffset = hopPayloadsSize - BS.length filler - !streamPart = BS.take (BS.length extended) - (BS.drop streamOffset stream) - !newFiller = xorBytes extended streamPart - in go newFiller sts szs - go !filler _ _ = filler -{-# INLINE generateFiller #-} - --- | Wrap all hops in reverse order. -wrapAllHops - :: [SharedSecret] - -> [BS.ByteString] - -> [BS.ByteString] - -> BS.ByteString + step f (s, n) = + let !ext = f <> BS.replicate n 0 + !off = 1300 - BS.length f + in xor_bytes ext (BS.take (BS.length ext) (BS.drop off s)) + +-- wrap the hops' payloads, given (shared secret, rho stream, payload) +-- from the final hop to the first, returning hop_payloads and the HMAC +wrap + :: BS.ByteString -> BS.ByteString -> BS.ByteString + -> [(SharedSecret, BS.ByteString, BS.ByteString)] -> (BS.ByteString, BS.ByteString) -wrapAllHops !secrets !streams !payloads !filler !assocData !initPayloads = - let !paired = reverse (zip3 secrets streams payloads) - !numHops = length paired - !initHmac = BS.replicate hmacSize 0 - in go numHops initPayloads initHmac paired +wrap ad fill = go True (BS.replicate 32 0) where - go !_ !hpBuf !hmac [] = (hpBuf, hmac) - go !remaining !hpBuf !hmac ((ss, stream, payload):rest) = - let !isLastHop = remaining == - length (reverse (zip secrets payloads)) - (!newPayloads, !newHmac) = - wrapHop ss stream payload hmac hpBuf - assocData filler isLastHop - in go (remaining - 1) newPayloads newHmac rest - --- | Wrap a single hop's payload, given its shared secret and rho --- stream. -wrapHop - :: SharedSecret - -> BS.ByteString - -> BS.ByteString - -> BS.ByteString - -> BS.ByteString - -> BS.ByteString - -> BS.ByteString - -> Bool - -> (BS.ByteString, BS.ByteString) -wrapHop !ss !stream !payload !hmac !hpBuf !assocData !filler !isFinalHop = - let !payloadLen = BS.length payload - !lenBytes = encodeBigSize (fromIntegral payloadLen) - !shiftSize = BS.length lenBytes + payloadLen + hmacSize - !shifted = BS.take (hopPayloadsSize - shiftSize) hpBuf - !prepended = lenBytes <> payload <> hmac <> shifted - !obfuscated = xorBytes prepended (BS.take hopPayloadsSize 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 !hpBuf !filler = - let !fillerLen = BS.length filler - !prefix = BS.take (hopPayloadsSize - fillerLen) hpBuf - 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 #-} + go _ !mac !buf [] = (buf, mac) + go final !mac !buf ((ss, stream, pl) : rest) = + let !len = BOLT1.encode_bigsize (fromIntegral (BS.length pl)) + !n = BS.length len + BS.length pl + 32 + !shifted = BS.concat [len, pl, mac, BS.take (1300 - n) buf] + !obf = xor_bytes shifted stream + !buf' | final = BS.take (1300 - BS.length fill) obf <> fill + | otherwise = obf + !mac' = hmac (derive_mu ss) (buf' <> ad) + in go False mac' buf' rest diff --git a/lib/Lightning/Protocol/BOLT4/Error.hs b/lib/Lightning/Protocol/BOLT4/Error.hs @@ -1,6 +1,5 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} -{-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_HADDOCK hide #-} +{-# LANGUAGE DeriveGeneric #-} -- | -- Module: Lightning.Protocol.BOLT4.Error @@ -8,222 +7,115 @@ -- 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. +-- Returning errors: construction, wrapping and attribution of return +-- packets. module Lightning.Protocol.BOLT4.Error ( - -- * Types ErrorPacket(..) - , AttributionResult(..) - , minErrorPacketSize - - -- * Error construction (failing node) - , constructError - - -- * Error forwarding (intermediate node) - , wrapError - - -- * Error unwrapping (origin node) - , unwrapError + , Attribution(..) + , construct_error + , wrap_error + , unwrap_error ) where -import Data.Bits (xor) +import Control.DeepSeq (NFData) 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 GHC.Generics (Generic) +import qualified Lightning.Protocol.BOLT1 as BOLT1 +import Lightning.Protocol.BOLT4.Codec import Lightning.Protocol.BOLT4.Prim -import Lightning.Protocol.BOLT4.Types (FailureMessage) +import Lightning.Protocol.BOLT4.Types --- | Wrapped error packet ready for return to origin. +-- | An obfuscated return packet (the @reason@ of @update_fail_htlc@). newtype ErrorPacket = ErrorPacket BS.ByteString - deriving (Eq, Show) + deriving (Eq, Show, Generic) --- | 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 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 #-} +instance NFData ErrorPacket --- 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. --- --- Returns 'Nothing' if the obfuscation stream cannot be generated. -constructError - :: SharedSecret -- ^ from packet processing - -> FailureMessage -- ^ failure details - -> Maybe ErrorPacket -constructError !ss !failure = do - let !um = deriveUm ss - !ammag = deriveAmmag ss - !inner = buildErrorMessage um failure - ErrorPacket <$> obfuscateError ammag inner -{-# INLINE constructError #-} - --- | Wrap an existing error packet for forwarding back. --- --- Each intermediate node wraps the error with its own layer. --- --- Returns 'Nothing' if the obfuscation stream cannot be generated. -wrapError - :: SharedSecret -- ^ this node's shared secret - -> ErrorPacket -- ^ error from downstream - -> Maybe ErrorPacket -wrapError !ss (ErrorPacket !packet) = - let !ammag = deriveAmmag ss - in ErrorPacket <$> obfuscateError ammag packet -{-# 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 - in case deobfuscateError ammag packet of - Nothing -> UnknownOrigin packet - Just deobfuscated - | verifyErrorHmac um deobfuscated -> - case parseErrorMessage (BS.drop 32 deobfuscated) of - Just msg -> Attributed idx msg - Nothing -> UnknownOrigin deobfuscated - | otherwise -> go (idx + 1) deobfuscated rest - --- Internal functions --------------------------------------------------------- - --- | Build the inner error message structure. +-- | The origin of a return packet, as found by 'unwrap_error'. +data Attribution + = Attributed {-# UNPACK #-} !Int !FailureMessage + -- ^ the hop with this index (from 0, in route order) returned the + -- failure + | MalformedFailure {-# UNPACK #-} !Int + -- ^ the hop with this index returned the packet, but its failure + -- message can't be decoded + | UnknownOrigin + -- ^ no hop's HMAC matches + deriving (Eq, Show, Generic) + +instance NFData Attribution + +-- | Construct a return packet at the failing node, given the shared +-- secret from processing the onion and the failure. -- --- Format: HMAC (32) || len (2) || message || pad_len (2) || padding +-- The failure message is padded so that its length plus that of the +-- padding is at least 256 bytes. Fails if the failure message can't +-- be encoded, or exceeds 65535 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 - -> BS.ByteString -- ^ complete message with HMAC -buildErrorMessage (DerivedKey !umKey) !failure = - let !encoded = encodeFailureMessage failure - !msgLen = BS.length encoded - !padLen = max 0 (minErrorPacketSize - msgLen) - !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. +-- >>> let Just ss = shared_secret (BS.replicate 32 0x01) +-- >>> let fm = FailureMessage TemporaryNodeFailure BOLT1.empty_tlv_stream +-- >>> let Right (ErrorPacket pkt) = construct_error ss fm +-- >>> BS.length pkt +-- 292 +construct_error + :: SharedSecret + -> FailureMessage + -> Either EncodeError ErrorPacket +construct_error ss fm = do + msg <- encode_failure_message fm + let len = BS.length msg + if len > 65535 + then Left FieldTooLong + else do + let pad = max 0 (256 - len) + body = BS.concat + [ BOLT1.encode_u16 (fromIntegral len), msg + , BOLT1.encode_u16 (fromIntegral pad), BS.replicate pad 0 ] + mac = hmac (derive_um ss) body + pure (wrap_error ss (ErrorPacket (mac <> body))) + +-- | Wrap a return packet from downstream at an intermediate node, given +-- the shared secret from processing the onion. -- --- XORs the entire packet with pseudo-random stream. -obfuscateError - :: DerivedKey -- ^ ammag key - -> BS.ByteString -- ^ error packet - -> Maybe BS.ByteString -- ^ obfuscated packet -obfuscateError !ammag !packet = do - stream <- generateStream ammag (BS.length packet) - pure $! xorBytes packet stream -{-# INLINE obfuscateError #-} - --- | Remove one layer of obfuscation from error packet. +-- >>> let Just ss = shared_secret (BS.replicate 32 0x01) +-- >>> wrap_error ss (wrap_error ss (ErrorPacket "packet")) +-- ErrorPacket "packet" +wrap_error :: SharedSecret -> ErrorPacket -> ErrorPacket +wrap_error ss (ErrorPacket p) = + ErrorPacket (xor_bytes p (keystream (derive_ammag ss) (BS.length p))) + +-- | Find the origin of a return packet at the origin node, given the +-- shared secrets from 'Lightning.Protocol.BOLT4.construct', in route +-- order. -- --- XOR is its own inverse, so same as obfuscation. -deobfuscateError - :: DerivedKey -- ^ ammag key - -> BS.ByteString -- ^ obfuscated packet - -> Maybe 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) +-- >>> let Just ss0 = shared_secret (BS.replicate 32 0x01) +-- >>> let Just ss1 = shared_secret (BS.replicate 32 0x02) +-- >>> let fm = FailureMessage TemporaryNodeFailure BOLT1.empty_tlv_stream +-- >>> let Right pkt = construct_error ss1 fm +-- >>> unwrap_error [ss0, ss1] (wrap_error ss0 pkt) == Attributed 1 fm +-- True +-- >>> unwrap_error [ss0] (wrap_error ss0 pkt) +-- UnknownOrigin +unwrap_error :: [SharedSecret] -> ErrorPacket -> Attribution +unwrap_error = go 0 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 #-} + go _ [] _ = UnknownOrigin + go i (ss : rest) p = + let ErrorPacket q = wrap_error ss p + (mac, body) = BS.splitAt 32 q + in if ct_eq mac (hmac (derive_um ss) body) + then either (const (MalformedFailure i)) (Attributed i) + (failure_message body) + else go (i + 1) rest (ErrorPacket q) + +-- The failure message of a return packet (after its HMAC). +failure_message :: BS.ByteString -> Either DecodeError FailureMessage +failure_message body = case BOLT1.decode_u16 body of + Just (len, r0) + | BS.length r0 >= fromIntegral len + , let (msg, r1) = BS.splitAt (fromIntegral len) r0 + , Just (pad, r2) <- BOLT1.decode_u16 r1 + , BS.length r2 >= fromIntegral pad + -> decode_failure_message msg + _ -> Left InvalidLength diff --git a/lib/Lightning/Protocol/BOLT4/Internal.hs b/lib/Lightning/Protocol/BOLT4/Internal.hs @@ -1,40 +0,0 @@ -{-# OPTIONS_HADDOCK hide #-} - --- | --- Module: Lightning.Protocol.BOLT4.Internal --- Copyright: (c) 2025 Jared Tobin --- License: MIT --- Maintainer: Jared Tobin <jared@ppad.tech> --- --- Internal definitions for BOLT4. --- --- This module exports unsafe constructors that bypass --- validation. Use only in tests or trusted internal code. - -module Lightning.Protocol.BOLT4.Internal ( - -- * Unsafe constructors - unsafeHmac32 - , unsafeHopPayloads - , unsafePaymentSecret - ) where - -import qualified Data.ByteString as BS -import Lightning.Protocol.BOLT4.Types - --- | Construct an 'Hmac32' without length validation. --- --- For test use only. -unsafeHmac32 :: BS.ByteString -> Hmac32 -unsafeHmac32 = Hmac32 - --- | Construct a 'HopPayloads' without length validation. --- --- For test use only. -unsafeHopPayloads :: BS.ByteString -> HopPayloads -unsafeHopPayloads = HopPayloads - --- | Construct a 'PaymentSecret' without length validation. --- --- For test use only. -unsafePaymentSecret :: BS.ByteString -> PaymentSecret -unsafePaymentSecret = PaymentSecret diff --git a/lib/Lightning/Protocol/BOLT4/Prim.hs b/lib/Lightning/Protocol/BOLT4/Prim.hs @@ -1,4 +1,4 @@ -{-# OPTIONS_HADDOCK prune #-} +{-# OPTIONS_HADDOCK hide #-} {-# LANGUAGE BangPatterns #-} {-# LANGUAGE OverloadedStrings #-} @@ -8,221 +8,251 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Low-level cryptographic primitives for BOLT4 onion routing. +-- Keys, secrets and the cryptographic primitives of BOLT #4. module Lightning.Protocol.BOLT4.Prim ( - -- * Types - SharedSecret(..) - , DerivedKey(..) - , BlindingFactor(..) - - -- * Key derivation - , deriveRho - , deriveMu - , deriveUm - , derivePad - , deriveAmmag - - -- * Shared secret computation - , computeSharedSecret - - -- * Blinding factor computation - , computeBlindingFactor - - -- * Key blinding - , blindPubKey - , blindSecKey - - -- * Stream generation - , generateStream - - -- * HMAC operations - , computeHmac - , verifyHmac + -- * Secret keys + SecretKey + , secret_key + , public_key + , sk_bytes + , sk_pub + + -- * Shared secrets + , SharedSecret + , shared_secret + , un_shared_secret + , ecdh + + -- * Derived keys + , DerivedKey + , un_derived_key + , derive_rho + , derive_mu + , derive_um + , derive_ammag + , derive_pad + + -- * Blinding + , blinding_factor + , blinded_node_tweak + , blind_pub + , blind_scalar + + -- * Points + , to_point + , from_point + + -- * Streams and MACs + , keystream + , hmac + , ct_eq + , xor_bytes ) where +import Control.DeepSeq (NFData(..)) import qualified Crypto.Cipher.ChaCha20 as ChaCha import qualified Crypto.Curve.Secp256k1 as Secp256k1 import qualified Crypto.Hash.SHA256 as SHA256 import Data.Bits (xor) import qualified Data.ByteString as BS -import qualified Data.List as L -import Data.Word (Word8, Word32) +import qualified Lightning.Protocol.BOLT1 as BOLT1 import qualified Numeric.Montgomery.Secp256k1.Scalar as S --- | 32-byte shared secret derived from ECDH. -newtype SharedSecret = SharedSecret BS.ByteString - deriving (Eq, Show) - --- | 32-byte derived key (rho, mu, um, pad, ammag). -newtype DerivedKey = DerivedKey BS.ByteString - deriving (Eq, Show) - --- | 32-byte blinding factor for ephemeral key updates. -newtype BlindingFactor = BlindingFactor BS.ByteString - deriving (Eq, Show) - --- Key derivation ------------------------------------------------------------ +-- secret keys ---------------------------------------------------------------- --- | Derive rho key for obfuscation stream generation. +-- | A secp256k1 secret key: a session key, a node's private key, or the +-- seed of a blinded path. -- --- @rho = HMAC-SHA256(key="rho", data=shared_secret)@ -deriveRho :: SharedSecret -> DerivedKey -deriveRho = deriveKey "rho" -{-# INLINE deriveRho #-} - --- | Derive mu key for HMAC computation. +-- This is secret material: its 'Show' instance is redacted and it +-- has no 'Eq' instance. +data SecretKey = SecretKey + !BS.ByteString -- the key + !Secp256k1.Projective -- its public key + !BOLT1.Point -- its serialized public key + +instance Show SecretKey where + showsPrec d _ = showParen (d > 10) $ + showString "SecretKey <redacted>" + +instance NFData SecretKey where + rnf (SecretKey k p q) = rnf k `seq` p `seq` rnf q + +-- | Construct a 'SecretKey' from exactly 32 big-endian bytes encoding an +-- integer in [1, n - 1], where n is the secp256k1 group order. -- --- @mu = HMAC-SHA256(key="mu", data=shared_secret)@ -deriveMu :: SharedSecret -> DerivedKey -deriveMu = deriveKey "mu" -{-# INLINE deriveMu #-} - --- | Derive um key for return error HMAC. +-- >>> secret_key (BS.replicate 32 0x41) +-- Just (SecretKey <redacted>) +-- >>> secret_key (BS.replicate 32 0x00) +-- Nothing +-- >>> secret_key (BS.replicate 31 0x41) +-- Nothing +secret_key :: BS.ByteString -> Maybe SecretKey +secret_key bs + | BS.length bs /= 32 = Nothing + | otherwise = do + pub <- Secp256k1.derive_pub (Secp256k1.unsafe_roll32 bs) + pt <- to_point pub + pure $! SecretKey bs pub pt + +-- | The public key of a 'SecretKey'. -- --- @um = HMAC-SHA256(key="um", data=shared_secret)@ -deriveUm :: SharedSecret -> DerivedKey -deriveUm = deriveKey "um" -{-# INLINE deriveUm #-} +-- >>> let Just sk = secret_key (BS.replicate 32 0x41) +-- >>> BS.length (BOLT1.un_point (public_key sk)) +-- 33 +public_key :: SecretKey -> BOLT1.Point +public_key (SecretKey _ _ p) = p +{-# INLINE public_key #-} --- | Derive pad key for filler generation. --- --- @pad = HMAC-SHA256(key="pad", data=shared_secret)@ -derivePad :: SharedSecret -> DerivedKey -derivePad = deriveKey "pad" -{-# INLINE derivePad #-} +sk_bytes :: SecretKey -> BS.ByteString +sk_bytes (SecretKey k _ _) = k +{-# INLINE sk_bytes #-} + +sk_pub :: SecretKey -> Secp256k1.Projective +sk_pub (SecretKey _ p _) = p +{-# INLINE sk_pub #-} + +-- shared secrets ------------------------------------------------------------- --- | Derive ammag key for error obfuscation. +-- | A 32-byte shared secret, established between the origin of an onion +-- and one of its hops. -- --- @ammag = HMAC-SHA256(key="ammag", data=shared_secret)@ -deriveAmmag :: SharedSecret -> DerivedKey -deriveAmmag = deriveKey "ammag" -{-# INLINE deriveAmmag #-} +-- This is secret material: its 'Show' instance is redacted and its +-- 'Eq' instance runs in constant time. +newtype SharedSecret = SharedSecret BS.ByteString --- Internal helper for key derivation. -deriveKey :: BS.ByteString -> SharedSecret -> DerivedKey -deriveKey !keyType (SharedSecret !ss) = - let SHA256.MAC !result = SHA256.hmac keyType ss - in DerivedKey result -{-# INLINE deriveKey #-} +instance Eq SharedSecret where + SharedSecret a == SharedSecret b = ct_eq a b --- Shared secret computation ------------------------------------------------- +instance Show SharedSecret where + showsPrec d _ = showParen (d > 10) $ + showString "SharedSecret <redacted>" --- | Compute shared secret from ECDH. --- --- Takes a 32-byte secret key and a public key. --- Returns SHA256 of the compressed ECDH point (33 bytes). -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 #-} - --- Blinding factor ----------------------------------------------------------- - --- | Compute blinding factor for ephemeral key updates. --- --- @blinding_factor = SHA256(ephemeral_pubkey || shared_secret)@ -computeBlindingFactor - :: Secp256k1.Projective -- ^ ephemeral public key - -> SharedSecret -- ^ shared secret - -> BlindingFactor -computeBlindingFactor !pub (SharedSecret !ss) = - let !pubBytes = Secp256k1.serialize_point pub - !combined = pubBytes <> ss - !hashed = SHA256.hash combined - in BlindingFactor hashed -{-# INLINE computeBlindingFactor #-} - --- Key blinding -------------------------------------------------------------- - --- | Blind a public key by multiplying with blinding factor. --- --- @new_pubkey = pubkey * blinding_factor@ -blindPubKey - :: Secp256k1.Projective - -> BlindingFactor - -> Maybe Secp256k1.Projective -blindPubKey !pub (BlindingFactor !bf) = do - sk <- Secp256k1.roll32 bf - Secp256k1.mul pub sk -{-# INLINE blindPubKey #-} - --- | Blind a secret key by multiplying with blinding factor (mod curve order). --- --- @new_seckey = seckey * blinding_factor (mod q)@ --- --- Uses Montgomery multiplication from ppad-fixed for efficiency. --- Takes a 32-byte secret key and returns a 32-byte blinded secret key. -blindSecKey - :: BS.ByteString -- ^ 32-byte secret key - -> BlindingFactor -- ^ blinding factor - -> Maybe BS.ByteString -- ^ 32-byte blinded secret key -blindSecKey !secBs (BlindingFactor !bf) - | BS.length secBs /= 32 = Nothing - | BS.length bf /= 32 = Nothing - | otherwise = - let !secW = Secp256k1.unsafe_roll32 secBs - !bfW = Secp256k1.unsafe_roll32 bf - !secM = S.to secW - !bfM = S.to bfW - !resultM = S.mul secM bfM - !resultW = S.retr resultM - in Just $! Secp256k1.unroll32 resultW -{-# INLINE blindSecKey #-} - --- Stream generation --------------------------------------------------------- - --- | Generate pseudo-random byte stream using ChaCha20. --- --- Uses derived key as ChaCha20 key, 96-bit zero nonce, counter=0. --- Encrypts zeros to produce keystream. +instance NFData SharedSecret where + rnf (SharedSecret bs) = rnf bs + +-- | Construct a 'SharedSecret' from exactly 32 bytes (e.g. to restore one +-- that was stored with 'un_shared_secret'). -- --- Returns 'Nothing' if ChaCha20 fails (e.g. the key is not 32 bytes). -generateStream - :: DerivedKey -- ^ rho or ammag key - -> Int -- ^ desired length - -> Maybe BS.ByteString -generateStream (DerivedKey !key) !len = - let !nonce = BS.replicate 12 0 - !zeros = BS.replicate len 0 - in either (const Nothing) Just - (ChaCha.cipher key (0 :: Word32) nonce zeros) -{-# INLINE generateStream #-} - --- HMAC operations ----------------------------------------------------------- - --- | Compute HMAC-SHA256 for packet integrity. -computeHmac - :: DerivedKey -- ^ mu key - -> BS.ByteString -- ^ hop_payloads - -> BS.ByteString -- ^ associated_data - -> BS.ByteString -- ^ 32-byte HMAC -computeHmac (DerivedKey !key) !payloads !assocData = - let SHA256.MAC !result = SHA256.hmac key (payloads <> assocData) - in result -{-# INLINE computeHmac #-} - --- | Constant-time HMAC comparison. -verifyHmac - :: BS.ByteString -- ^ expected - -> BS.ByteString -- ^ computed - -> Bool -verifyHmac !expected !computed - | BS.length expected /= BS.length computed = False - | otherwise = constantTimeEq expected computed -{-# INLINE verifyHmac #-} - --- Constant-time equality comparison. -constantTimeEq :: BS.ByteString -> BS.ByteString -> Bool -constantTimeEq !a !b = - let !diff = L.foldl' (\acc (x, y) -> acc `xor` (x `xor` y)) (0 :: Word8) - (BS.zip a b) - in diff == 0 -{-# INLINE constantTimeEq #-} +-- >>> let Just ss = shared_secret (BS.replicate 32 0x01) +-- >>> BS.length (un_shared_secret ss) +-- 32 +-- >>> shared_secret "too short" +-- Nothing +shared_secret :: BS.ByteString -> Maybe SharedSecret +shared_secret bs + | BS.length bs == 32 = Just (SharedSecret bs) + | otherwise = Nothing +{-# INLINE shared_secret #-} + +-- | The bytes of a 'SharedSecret'. +un_shared_secret :: SharedSecret -> BS.ByteString +un_shared_secret (SharedSecret bs) = bs +{-# INLINE un_shared_secret #-} + +-- | ECDH per BOLT #4: SHA256 of the compressed product of a point and a +-- 32-byte scalar. Fails if the scalar is not in [1, n - 1]. +ecdh :: BS.ByteString -> Secp256k1.Projective -> Maybe SharedSecret +ecdh k pub = do + pt <- Secp256k1.mul pub (Secp256k1.unsafe_roll32 k) + pure $! SharedSecret (SHA256.hash (Secp256k1.serialize_point pt)) + +-- derived keys --------------------------------------------------------------- + +-- A 32-byte key derived from a shared secret. +newtype DerivedKey = DerivedKey BS.ByteString + +un_derived_key :: DerivedKey -> BS.ByteString +un_derived_key (DerivedKey k) = k +{-# INLINE un_derived_key #-} + +derive :: BS.ByteString -> BS.ByteString -> DerivedKey +derive label ss = + let SHA256.MAC k = SHA256.hmac label ss + in DerivedKey k +{-# INLINE derive #-} + +derive_rho :: SharedSecret -> DerivedKey +derive_rho (SharedSecret ss) = derive "rho" ss + +derive_mu :: SharedSecret -> DerivedKey +derive_mu (SharedSecret ss) = derive "mu" ss + +derive_um :: SharedSecret -> DerivedKey +derive_um (SharedSecret ss) = derive "um" ss + +derive_ammag :: SharedSecret -> DerivedKey +derive_ammag (SharedSecret ss) = derive "ammag" ss + +-- The pad key is derived from the session key itself. +derive_pad :: SecretKey -> DerivedKey +derive_pad (SecretKey k _ _) = derive "pad" k + +-- blinding ------------------------------------------------------------------- + +-- SHA256(E || ss), the factor by which ephemeral keys are blinded. +blinding_factor :: Secp256k1.Projective -> SharedSecret -> BS.ByteString +blinding_factor e (SharedSecret ss) = + SHA256.hash (Secp256k1.serialize_point e <> ss) + +-- HMAC256("blinded_node_id", ss), the factor by which node ids are +-- blinded in a blinded route. +blinded_node_tweak :: SharedSecret -> BS.ByteString +blinded_node_tweak (SharedSecret ss) = + let SHA256.MAC t = SHA256.hmac "blinded_node_id" ss + in t + +-- Multiply a point by a 32-byte factor. +blind_pub + :: Secp256k1.Projective -> BS.ByteString -> Maybe Secp256k1.Projective +blind_pub p t = Secp256k1.mul p (Secp256k1.unsafe_roll32 t) + +-- Multiply a 32-byte scalar by a 32-byte factor, mod n. Fails if the +-- product is zero. +blind_scalar :: BS.ByteString -> BS.ByteString -> Maybe BS.ByteString +blind_scalar k t = + let !r = S.retr (S.mul (S.to (Secp256k1.unsafe_roll32 k)) + (S.to (Secp256k1.unsafe_roll32 t))) + in if Secp256k1.ge r + then Just $! Secp256k1.unroll32 r + else Nothing + +-- points --------------------------------------------------------------------- + +to_point :: Secp256k1.Projective -> Maybe BOLT1.Point +to_point = BOLT1.point . Secp256k1.serialize_point +{-# INLINE to_point #-} + +from_point :: BOLT1.Point -> Maybe Secp256k1.Projective +from_point = Secp256k1.parse_point . BOLT1.un_point +{-# INLINE from_point #-} + +-- streams and MACs ----------------------------------------------------------- + +-- The ChaCha20 keystream of the given length under a derived key, with +-- an all-zero nonce. The cipher fails only for a key or nonce of the +-- wrong length, or past 256 GiB of output, none of which can occur +-- here; the empty result in that case truncates any output XORed with +-- it, rather than leaving it unencrypted. +keystream :: DerivedKey -> Int -> BS.ByteString +keystream (DerivedKey k) n = + case ChaCha.cipher k 0 (BS.replicate 12 0) (BS.replicate n 0) of + Right s -> s + Left _ -> BS.empty + +-- HMAC-SHA256 under a derived key. +hmac :: DerivedKey -> BS.ByteString -> BS.ByteString +hmac (DerivedKey k) m = + let SHA256.MAC h = SHA256.hmac k m + in h +{-# INLINE hmac #-} + +-- Constant-time equality (variable-time only in the lengths). +ct_eq :: BS.ByteString -> BS.ByteString -> Bool +ct_eq a b = SHA256.MAC a == SHA256.MAC b +{-# INLINE ct_eq #-} + +-- XOR, truncated to the shorter input. +xor_bytes :: BS.ByteString -> BS.ByteString -> BS.ByteString +xor_bytes = BS.packZipWith xor +{-# INLINE xor_bytes #-} diff --git a/lib/Lightning/Protocol/BOLT4/Process.hs b/lib/Lightning/Protocol/BOLT4/Process.hs @@ -1,7 +1,5 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} +{-# OPTIONS_HADDOCK hide #-} {-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE OverloadedStrings #-} -- | -- Module: Lightning.Protocol.BOLT4.Process @@ -9,233 +7,159 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Onion packet processing for BOLT4. +-- Onion packet processing. module Lightning.Protocol.BOLT4.Process ( - -- * Processing - process - - -- * Rejection reasons - , RejectReason(..) + ProcessResult(..) + , ForwardInfo(..) + , ReceiveInfo(..) + , process ) where -import Data.Bits (xor) +import Control.DeepSeq (NFData) import qualified Crypto.Curve.Secp256k1 as Secp256k1 import qualified Data.ByteString as BS -import Data.Word (Word8) import GHC.Generics (Generic) -import Lightning.Protocol.BOLT4.Codec -import Lightning.Protocol.BOLT4.Internal +import qualified Lightning.Protocol.BOLT1 as BOLT1 +import Lightning.Protocol.BOLT4.Blinding (unblind) +import Lightning.Protocol.BOLT4.Codec (decode_hop_payload) import Lightning.Protocol.BOLT4.Prim import Lightning.Protocol.BOLT4.Types --- | Reasons for rejecting a packet. -data RejectReason - = InvalidVersion !Word8 -- ^ Version byte is not 0x00 - | InvalidEphemeralKey -- ^ Malformed public key - | HmacMismatch -- ^ HMAC verification failed - | InvalidPayload !String -- ^ Malformed hop payload +-- | The result of processing an onion packet. +data ProcessResult + = Forward !ForwardInfo + -- ^ forward the onion to the next hop + | Receive !ReceiveInfo + -- ^ this node is the onion's final hop deriving (Eq, Show, Generic) --- | Process an incoming onion packet. +instance NFData ProcessResult + +-- | What a forwarding node learns from an onion. +data ForwardInfo = ForwardInfo + { fwd_payload :: !HopPayload + -- ^ this node's payload + , fwd_blinded :: !(Maybe BlindedInfo) + -- ^ the decrypted @encrypted_recipient_data@ and next path key, in + -- a blinded route + , fwd_next_packet :: !OnionPacket + -- ^ the onion for the next hop + , fwd_shared_secret :: !SharedSecret + -- ^ the shared secret, to wrap returned errors with + } deriving (Eq, Show, Generic) + +instance NFData ForwardInfo + +-- | What the final node learns from an onion. +data ReceiveInfo = ReceiveInfo + { rcv_payload :: !HopPayload + -- ^ this node's payload + , rcv_blinded :: !(Maybe BlindedInfo) + -- ^ the decrypted @encrypted_recipient_data@, in a blinded route + , rcv_shared_secret :: !SharedSecret + -- ^ the shared secret, to construct errors with + } deriving (Eq, Show, Generic) + +instance NFData ReceiveInfo + +-- | Process an onion packet, given this node's private key, the packet, +-- its associated data (the payment hash, for a payment), and the +-- @path_key@ received with it (in @update_add_htlc@), if any. +-- +-- In a blinded route, the onion is encrypted to this node's blinded +-- node id: given a @path_key@, the matching private key is derived +-- from it. The @encrypted_recipient_data@ in the payload is decrypted +-- with the @path_key@, or, at the introduction node, with the +-- payload's @current_path_key@; a path key without +-- @encrypted_recipient_data@, or both kinds of path key, are +-- rejected. -- --- Takes the receiving node's private key, the incoming packet, and --- associated data (typically the payment hash). +-- The payload is otherwise returned as is. The caller must apply the +-- remaining reader requirements of BOLT #4: the presence of the +-- fields required for forwarding or receiving, the HTLC amount and +-- expiry checks (in a blinded route, against @payment_relay@ and +-- @payment_constraints@), @allowed_features@, the rule that +-- @encrypted_recipient_data@ hold only one of @short_channel_id@ and +-- @next_node_id@, and the rejection of replayed HMACs. -- --- Returns either a rejection reason or the processing result --- (forward to next hop or receive at final destination). +-- >>> let Just session = secret_key (BS.replicate 32 0x41) +-- >>> let Just node = secret_key (BS.replicate 32 0x42) +-- >>> let pl = empty_hop_payload { hp_outgoing_cltv_value = Just 144 } +-- >>> let ad = BS.replicate 32 0 +-- >>> let Right (pkt, _) = construct session [Hop (public_key node) pl] ad +-- >>> let Right (Receive info) = process node pkt ad Nothing +-- >>> hp_outgoing_cltv_value (rcv_payload info) +-- Just 144 process - :: BS.ByteString -- ^ 32-byte secret key of this node - -> OnionPacket -- ^ incoming onion packet - -> BS.ByteString -- ^ associated data (payment hash) - -> Either RejectReason ProcessResult -process !secKey !packet !assocData = do - -- Step 1: Validate version - validateVersion packet - - -- Step 2: Parse ephemeral public key - ephemeral <- parseEphemeralKey packet - - -- Step 3: Compute shared secret - ss <- case computeSharedSecret secKey ephemeral of - Nothing -> Left InvalidEphemeralKey - Just s -> Right s - - -- Step 4: Derive keys - let !muKey = deriveMu ss - !rhoKey = deriveRho ss - - -- Step 5: Verify HMAC - if not (verifyPacketHmac muKey packet assocData) - then Left HmacMismatch - else pure () - - -- Step 6: Decrypt hop payloads - decrypted <- case decryptPayloads rhoKey - (unHopPayloads (opHopPayloads packet)) of - Nothing -> Left InvalidEphemeralKey - Just d -> Right d - - -- Step 7: Extract payload - (payloadBytes, nextHmac, remaining) <- - extractPayload decrypted - - -- Step 8: Parse payload TLV - hp <- case decodeHopPayload payloadBytes of - Nothing -> Left (InvalidPayload "failed to decode TLV") - Just h -> Right h - - -- Step 9: Check if final hop - if isFinalHop nextHmac - then Right $! Receive $! ReceiveInfo - { riPayload = hp - , riSharedSecret = ss - } + :: SecretKey + -> OnionPacket + -> BS.ByteString + -> Maybe BOLT1.Point + -> Either ProcessError ProcessResult +process sk pkt ad mpk = do + let ver = onion_version pkt + if ver /= 0 then Left (InvalidVersion ver) else Right () + eph <- note InvalidPublicKey (from_point (onion_public_key pkt)) + -- with a path_key, the onion is encrypted to the blinded node id + blinding <- traverse path_secret mpk + key <- case blinding of + Nothing -> Right (sk_bytes sk) + Just (_, bss) -> + note InvalidPathKey (blind_scalar (sk_bytes sk) (blinded_node_tweak bss)) + ss <- note InvalidPublicKey (ecdh key eph) + let payloads = un_hop_payloads (onion_hop_payloads pkt) + mac = hmac (derive_mu ss) (payloads <> ad) + if ct_eq mac (un_hmac32 (onion_hmac pkt)) + then Right () + else Left HmacMismatch + let plain = xor_bytes (payloads <> BS.replicate 1300 0) + (keystream (derive_rho ss) 2600) + (pl, next_mac, rest) <- split_payload plain + hp <- either (Left . InvalidPayload) Right (decode_hop_payload pl) + binfo <- case (hp_encrypted_data hp, blinding, hp_current_path_key hp) of + (Nothing, Nothing, Nothing) -> Right Nothing + (Nothing, _, _) -> Left UnexpectedPathKey + (Just _, Just _, Just _) -> Left UnexpectedPathKey + (Just _, Nothing, Nothing) -> Left MissingPathKey + (Just enc, Just (e, bss), Nothing) -> Just <$> unblind bss e enc + (Just enc, Nothing, Just cpk) -> do + (e, bss) <- path_secret cpk + Just <$> unblind bss e enc + if BS.all (== 0) next_mac + then Right (Receive (ReceiveInfo hp binfo ss)) else do - -- Step 10: Prepare forward packet - nextPacket <- case prepareForward - ephemeral ss remaining nextHmac of - Nothing -> Left InvalidEphemeralKey - Just np -> Right np - - Right $! Forward $! ForwardInfo - { fiNextPacket = nextPacket - , fiPayload = hp - , fiSharedSecret = ss - } - --- | Validate packet version is 0x00. -validateVersion :: OnionPacket -> Either RejectReason () -validateVersion !packet - | opVersion packet == versionByte = Right () - | otherwise = Left (InvalidVersion (opVersion packet)) -{-# INLINE validateVersion #-} - --- | Parse and validate ephemeral public key from packet. -parseEphemeralKey - :: OnionPacket - -> Either RejectReason Secp256k1.Projective -parseEphemeralKey !packet = - case Secp256k1.parse_point (opEphemeralKey packet) of - Nothing -> Left InvalidEphemeralKey - Just pub -> Right pub -{-# INLINE parseEphemeralKey #-} - --- | Decrypt hop payloads by XORing with rho stream. --- --- Generates a stream of 2*1300 bytes and XORs with hop_payloads --- extended with 1300 zero bytes. -decryptPayloads - :: DerivedKey -- ^ rho key - -> BS.ByteString -- ^ hop_payloads (1300 bytes) - -> Maybe BS.ByteString - -- ^ decrypted (2600 bytes, first 1300 useful) -decryptPayloads !rhoKey !payloads = do - let !streamLen = 2 * hopPayloadsSize -- 2600 bytes - stream <- generateStream rhoKey streamLen - -- Extend payloads with zeros for the shift operation - let !extended = payloads <> BS.replicate hopPayloadsSize 0 - pure $! xorBytes stream extended -{-# INLINE decryptPayloads #-} - --- | 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 #-} - --- | Extract payload from decrypted buffer. --- --- Parses BigSize length prefix, extracts payload bytes and --- next HMAC. --- --- The length prefix, payload and next HMAC must fit in the 1300-byte --- hop_payloads region, so that at least 1300 bytes remain for the --- next hop. -extractPayload + eph' <- note InvalidPublicKey + (blind_pub eph (blinding_factor eph ss) >>= to_point) + let next = OnionPacket 0 eph' (HopPayloads rest) (Hmac32 next_mac) + Right (Forward (ForwardInfo hp binfo next ss)) + where + note :: ProcessError -> Maybe a -> Either ProcessError a + note e = maybe (Left e) Right + + -- a path key, and its shared secret with this node + path_secret + :: BOLT1.Point + -> Either ProcessError (Secp256k1.Projective, SharedSecret) + path_secret pk = do + e <- note InvalidPathKey (from_point pk) + bss <- note InvalidPathKey (ecdh (sk_bytes sk) e) + Right (e, bss) + +-- Split the decrypted 2600-byte buffer into the payload, the next HMAC +-- and the next hop_payloads. The length prefix, payload and HMAC must +-- fit in the 1300-byte hop_payloads. +split_payload :: BS.ByteString - -> Either RejectReason - (BS.ByteString, BS.ByteString, BS.ByteString) - -- ^ (payload_bytes, next_hmac, remaining_hop_payloads) -extractPayload !decrypted = do - -- Parse length prefix - (len64, afterLen) <- case decodeBigSize decrypted of - Nothing -> Left (InvalidPayload "invalid length prefix") - Just r -> Right r - - -- Validate length (in Word64, before converting to Int) - let !prefixLen = BS.length decrypted - BS.length afterLen - !maxLen = hopPayloadsSize - prefixLen - hmacSize - if len64 > fromIntegral maxLen - then Left (InvalidPayload "payload length exceeds hop_payloads") - else if len64 < 2 - then Left (InvalidPayload "payload length below 2") - else pure () - - -- Extract payload bytes - let !len = fromIntegral len64 :: Int - !payloadBytes = BS.take len afterLen - !afterPayload = BS.drop len afterLen - - -- Extract next HMAC (32 bytes) - if BS.length afterPayload < hmacSize - then Left (InvalidPayload "insufficient bytes for HMAC") - else do - let !nextHmac = BS.take hmacSize afterPayload - -- Remaining payloads: skip the HMAC, take first - -- 1300 bytes. Already "shifted" by payload extraction - !remaining = BS.drop hmacSize afterPayload - - Right (payloadBytes, nextHmac, remaining) - --- | Verify packet HMAC. --- --- Computes HMAC over (hop_payloads || associated_data) using --- mu key and compares with packet's HMAC using constant-time --- comparison. -verifyPacketHmac - :: DerivedKey -- ^ mu key - -> OnionPacket -- ^ packet with HMAC to verify - -> BS.ByteString -- ^ associated data - -> Bool -verifyPacketHmac !muKey !packet !assocData = - let !computed = computeHmac muKey - (unHopPayloads (opHopPayloads packet)) - assocData - in verifyHmac (unHmac32 (opHmac packet)) computed -{-# INLINE verifyPacketHmac #-} - --- | Prepare packet for forwarding to next hop. --- --- Computes blinded ephemeral key and constructs next --- OnionPacket. -prepareForward - :: Secp256k1.Projective -- ^ current ephemeral key - -> SharedSecret -- ^ shared secret (for blinding) - -> BS.ByteString -- ^ remaining hop_payloads - -> BS.ByteString -- ^ next HMAC - -> Maybe OnionPacket -prepareForward !ephemeral !ss !remaining !nextHmac = do - -- Compute blinding factor and blind ephemeral key - let !bf = computeBlindingFactor ephemeral ss - newEphemeral <- blindPubKey ephemeral bf - - -- Serialize new ephemeral key - let !newEphBytes = Secp256k1.serialize_point newEphemeral - - -- Truncate remaining to exactly 1300 bytes - let !newPayloads = BS.take hopPayloadsSize remaining - - -- Construct next packet - pure $! OnionPacket - { opVersion = versionByte - , opEphemeralKey = newEphBytes - , opHopPayloads = unsafeHopPayloads newPayloads - , opHmac = unsafeHmac32 nextHmac - } - --- | Check if this is the final hop. --- --- Final hop is indicated by next_hmac being all zeros. -isFinalHop :: BS.ByteString -> Bool -isFinalHop !hm = hm == BS.replicate hmacSize 0 -{-# INLINE isFinalHop #-} + -> Either ProcessError (BS.ByteString, BS.ByteString, BS.ByteString) +split_payload plain = case BOLT1.decode_bigsize plain of + Nothing -> Left InvalidPayloadLength + Just (len, r0) + | len < 2 -> Left InvalidPayloadLength + | len > fromIntegral (1300 - prefix - 32) -> Left InvalidPayloadLength + | otherwise -> + let (pl, r1) = BS.splitAt (fromIntegral len) r0 + (mac, r2) = BS.splitAt 32 r1 + in Right (pl, mac, BS.take 1300 r2) + where + prefix = BS.length plain - BS.length r0 diff --git a/lib/Lightning/Protocol/BOLT4/Types.hs b/lib/Lightning/Protocol/BOLT4/Types.hs @@ -1,7 +1,5 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} +{-# OPTIONS_HADDOCK hide #-} {-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE PatternSynonyms #-} -- | -- Module: Lightning.Protocol.BOLT4.Types @@ -9,341 +7,501 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Core data types for BOLT4 onion routing. +-- Data types for BOLT #4. module Lightning.Protocol.BOLT4.Types ( - -- * Fixed-size newtypes - Hmac32(..) - , hmac32 - , unHmac32 + -- * Onion packets + OnionPacket(..) , HopPayloads(..) - , hopPayloads - , unHopPayloads - , PaymentSecret(..) - , paymentSecret - , unPaymentSecret + , hop_payloads + , un_hop_payloads + , Hmac32(..) + , hmac32 + , un_hmac32 - -- * Packet types - , OnionPacket(..) + -- * Hop payloads , HopPayload(..) - , ShortChannelId(..) - , shortChannelId - , scidBlockHeight - , scidTxIndex - , scidOutputIndex - , scidWord64 + , empty_hop_payload , PaymentData(..) - , TlvRecord(..) - - -- * Error types + , PaymentSecret(..) + , payment_secret + , un_payment_secret + + -- * Route blinding + , BlindedPath(..) + , BlindedHop(..) + , BlindedHopData(..) + , empty_blinded_hop_data + , PaymentRelay(..) + , PaymentConstraints(..) + , BlindedInfo(..) + + -- * Failure messages , FailureMessage(..) - , FailureCode(..) - -- ** Flag bits - , pattern BADONION - , pattern PERM - , pattern NODE - , pattern UPDATE - -- ** Common failure codes - , pattern InvalidRealm - , pattern TemporaryNodeFailure - , pattern PermanentNodeFailure - , pattern RequiredNodeFeatureMissing - , pattern InvalidOnionVersion - , pattern InvalidOnionHmac - , pattern InvalidOnionKey - , pattern TemporaryChannelFailure - , pattern PermanentChannelFailure - , pattern AmountBelowMinimum - , pattern FeeInsufficient - , pattern IncorrectCltvExpiry - , pattern ExpiryTooSoon - , pattern IncorrectOrUnknownPaymentDetails - , pattern FinalIncorrectCltvExpiry - , pattern FinalIncorrectHtlcAmount - , pattern ChannelDisabled - , pattern ExpiryTooFar - , pattern InvalidOnionPayload - , pattern MppTimeout - - -- * Processing results - , ProcessResult(..) - , ForwardInfo(..) - , ReceiveInfo(..) - - -- * Constants - , onionPacketSize - , hopPayloadsSize - , hmacSize - , pubkeySize - , versionByte - , maxPayloadSize + , Failure(..) + , OnionHash(..) + , onion_hash + , un_onion_hash + , failure_code + , is_badonion + , is_perm + , is_node + , is_update + + -- * Errors + , DecodeError(..) + , EncodeError(..) + , ProcessError(..) ) where -import Data.Bits ((.&.), (.|.)) +import Control.DeepSeq (NFData(..)) +import Data.Bits ((.&.)) import qualified Data.ByteString as BS import Data.Word (Word8, Word16, Word32, Word64) import GHC.Generics (Generic) -import Lightning.Protocol.BOLT1.Prim - ( ShortChannelId(..), shortChannelId - , scidBlockHeight, scidTxIndex, scidOutputIndex, scidWord64 - ) -import Lightning.Protocol.BOLT4.Prim (SharedSecret) - --- Fixed-size newtypes ------------------------------------------------------- +import qualified Lightning.Protocol.BOLT1 as BOLT1 +import qualified Lightning.Protocol.BOLT4.Prim as P +import qualified Lightning.Protocol.BOLT9 as BOLT9 --- | 32-byte HMAC value. -newtype Hmac32 = Hmac32 BS.ByteString - deriving (Eq, Show, Generic) +-- onion packets -------------------------------------------------------------- --- | Construct an 'Hmac32' from a 32-byte 'BS.ByteString'. --- --- Returns 'Nothing' if the input is not exactly 32 bytes. -hmac32 :: BS.ByteString -> Maybe Hmac32 -hmac32 !bs - | BS.length bs == 32 = Just (Hmac32 bs) - | otherwise = Nothing -{-# INLINE hmac32 #-} +-- | An onion packet (@onion_packet@): 1366 bytes on the wire. +data OnionPacket = OnionPacket + { onion_version :: {-# UNPACK #-} !Word8 + -- ^ the version byte; 0 in this version of the protocol + , onion_public_key :: !BOLT1.Point + -- ^ the ephemeral public key + , onion_hop_payloads :: !HopPayloads + -- ^ the obfuscated hop payloads + , onion_hmac :: !Hmac32 + -- ^ the HMAC authenticating the packet + } deriving (Eq, Show, Generic) --- | Extract the raw bytes from an 'Hmac32'. -unHmac32 :: Hmac32 -> BS.ByteString -unHmac32 (Hmac32 bs) = bs -{-# INLINE unHmac32 #-} +instance NFData OnionPacket --- | 1300-byte hop payloads section. +-- | The 1300-byte @hop_payloads@ field of an onion packet. newtype HopPayloads = HopPayloads BS.ByteString deriving (Eq, Show, Generic) --- | Construct a 'HopPayloads' from a 1300-byte 'BS.ByteString'. +instance NFData HopPayloads + +-- | Construct 'HopPayloads' from exactly 1300 bytes. -- --- Returns 'Nothing' if the input is not exactly 1300 bytes. -hopPayloads :: BS.ByteString -> Maybe HopPayloads -hopPayloads !bs - | BS.length bs == hopPayloadsSize = Just (HopPayloads bs) - | otherwise = Nothing -{-# INLINE hopPayloads #-} - --- | Extract the raw bytes from 'HopPayloads'. -unHopPayloads :: HopPayloads -> BS.ByteString -unHopPayloads (HopPayloads bs) = bs -{-# INLINE unHopPayloads #-} - --- | 32-byte payment secret. -newtype PaymentSecret = PaymentSecret BS.ByteString +-- >>> let Just hp = hop_payloads (BS.replicate 1300 0) +-- >>> BS.length (un_hop_payloads hp) +-- 1300 +-- >>> hop_payloads (BS.replicate 1299 0) +-- Nothing +hop_payloads :: BS.ByteString -> Maybe HopPayloads +hop_payloads bs + | BS.length bs == 1300 = Just (HopPayloads bs) + | otherwise = Nothing +{-# INLINE hop_payloads #-} + +-- | The bytes of 'HopPayloads'. +un_hop_payloads :: HopPayloads -> BS.ByteString +un_hop_payloads (HopPayloads bs) = bs +{-# INLINE un_hop_payloads #-} + +-- | A 32-byte HMAC. +newtype Hmac32 = Hmac32 BS.ByteString deriving (Eq, Show, Generic) --- | Construct a 'PaymentSecret' from a 32-byte 'BS.ByteString'. +instance NFData Hmac32 + +-- | Construct an 'Hmac32' from exactly 32 bytes. -- --- Returns 'Nothing' if the input is not exactly 32 bytes. -paymentSecret :: BS.ByteString -> Maybe PaymentSecret -paymentSecret !bs - | BS.length bs == 32 = Just (PaymentSecret bs) - | otherwise = Nothing -{-# INLINE paymentSecret #-} +-- >>> fmap (BS.length . un_hmac32) (hmac32 (BS.replicate 32 0)) +-- Just 32 +-- >>> hmac32 "too short" +-- Nothing +hmac32 :: BS.ByteString -> Maybe Hmac32 +hmac32 bs + | BS.length bs == 32 = Just (Hmac32 bs) + | otherwise = Nothing +{-# INLINE hmac32 #-} --- | Extract the raw bytes from a 'PaymentSecret'. -unPaymentSecret :: PaymentSecret -> BS.ByteString -unPaymentSecret (PaymentSecret bs) = bs -{-# INLINE unPaymentSecret #-} +-- | The bytes of an 'Hmac32'. +un_hmac32 :: Hmac32 -> BS.ByteString +un_hmac32 (Hmac32 bs) = bs +{-# INLINE un_hmac32 #-} --- Packet types ------------------------------------------------------------- +-- hop payloads --------------------------------------------------------------- --- | Complete onion packet (1366 bytes). -data OnionPacket = OnionPacket - { opVersion :: {-# UNPACK #-} !Word8 - , opEphemeralKey :: !BS.ByteString -- ^ 33 bytes, compressed pubkey - , opHopPayloads :: !HopPayloads -- ^ 1300 bytes - , opHmac :: !Hmac32 -- ^ 32 bytes - } deriving (Eq, Show, Generic) - --- | Parsed hop payload after decryption. +-- | A hop's @payload@ TLV stream. +-- +-- Each known record has a typed field. 'hp_extra' holds the remaining +-- records: decoding fills it with the unknown odd records (unknown even +-- records are rejected), and encoding accepts any record in it whose +-- type isn't one of the known ones. data HopPayload = HopPayload - { hpAmtToForward :: !(Maybe Word64) -- ^ TLV type 2 - , hpOutgoingCltv :: !(Maybe Word32) -- ^ TLV type 4 - , hpShortChannelId :: !(Maybe ShortChannelId) -- ^ TLV type 6 - , hpPaymentData :: !(Maybe PaymentData) -- ^ TLV type 8 - , hpEncryptedData :: !(Maybe BS.ByteString) -- ^ TLV type 10 - , hpCurrentPathKey :: !(Maybe BS.ByteString) -- ^ TLV type 12 - , hpUnknownTlvs :: ![TlvRecord] -- ^ 16, 18 and unknown odd - } deriving (Eq, Show, Generic) - --- | Payment data for final hop (TLV type 8). -data PaymentData = PaymentData - { pdPaymentSecret :: !PaymentSecret -- ^ 32 bytes - , pdTotalMsat :: {-# UNPACK #-} !Word64 - } deriving (Eq, Show, Generic) - --- | Generic TLV record for unknown/extension types. -data TlvRecord = TlvRecord - { tlvType :: {-# UNPACK #-} !Word64 - , tlvValue :: !BS.ByteString + { hp_amt_to_forward :: !(Maybe BOLT1.MilliSatoshi) + -- ^ type 2: @amt_to_forward@ + , hp_outgoing_cltv_value :: !(Maybe Word32) + -- ^ type 4: @outgoing_cltv_value@ + , hp_short_channel_id :: !(Maybe BOLT1.ShortChannelId) + -- ^ type 6: @short_channel_id@ + , hp_payment_data :: !(Maybe PaymentData) + -- ^ type 8: @payment_data@ + , hp_encrypted_data :: !(Maybe BS.ByteString) + -- ^ type 10: @encrypted_recipient_data@ + , hp_current_path_key :: !(Maybe BOLT1.Point) + -- ^ type 12: @current_path_key@ + , hp_payment_metadata :: !(Maybe BS.ByteString) + -- ^ type 16: @payment_metadata@ + , hp_total_amount_msat :: !(Maybe BOLT1.MilliSatoshi) + -- ^ type 18: @total_amount_msat@ + , hp_extra :: !BOLT1.TlvStream + -- ^ other records } deriving (Eq, Show, Generic) --- Error types -------------------------------------------------------------- +instance NFData HopPayload --- | Failure message from intermediate or final node. -data FailureMessage = FailureMessage - { fmCode :: {-# UNPACK #-} !FailureCode - , fmData :: !BS.ByteString - , fmTlvs :: ![TlvRecord] +-- | The empty 'HopPayload', to be updated with record syntax. +-- +-- >>> let Just amt = BOLT1.milli_satoshi 1000 +-- >>> let hp = empty_hop_payload { hp_amt_to_forward = Just amt } +-- >>> encode_hop_payload hp +-- Right "\STX\STX\ETX\232" +empty_hop_payload :: HopPayload +empty_hop_payload = HopPayload + Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing + BOLT1.empty_tlv_stream + +-- | The @payment_data@ record of a final hop's payload. +data PaymentData = PaymentData + { pd_payment_secret :: !PaymentSecret + , pd_total_msat :: !BOLT1.MilliSatoshi } deriving (Eq, Show, Generic) --- | 2-byte failure code with flag bits. -newtype FailureCode = FailureCode Word16 - deriving (Eq, Show) +instance NFData PaymentData --- Flag bits +-- | A 32-byte payment secret. +-- +-- Its 'Show' instance is redacted and its 'Eq' instance runs in +-- constant time. +newtype PaymentSecret = PaymentSecret BS.ByteString --- | BADONION flag (0x8000): error was in parsing the onion. -pattern BADONION :: Word16 -pattern BADONION = 0x8000 +instance Eq PaymentSecret where + PaymentSecret a == PaymentSecret b = P.ct_eq a b --- | PERM flag (0x4000): permanent failure, do not retry. -pattern PERM :: Word16 -pattern PERM = 0x4000 +instance Show PaymentSecret where + showsPrec d _ = showParen (d > 10) $ + showString "PaymentSecret <redacted>" --- | NODE flag (0x2000): node failure rather than channel. -pattern NODE :: Word16 -pattern NODE = 0x2000 +instance NFData PaymentSecret where + rnf (PaymentSecret bs) = rnf bs --- | UPDATE flag (0x1000): channel update is attached. -pattern UPDATE :: Word16 -pattern UPDATE = 0x1000 +-- | Construct a 'PaymentSecret' from exactly 32 bytes. +-- +-- >>> payment_secret (BS.replicate 32 0x01) +-- Just (PaymentSecret <redacted>) +-- >>> payment_secret (BS.replicate 16 0x01) +-- Nothing +payment_secret :: BS.ByteString -> Maybe PaymentSecret +payment_secret bs + | BS.length bs == 32 = Just (PaymentSecret bs) + | otherwise = Nothing +{-# INLINE payment_secret #-} + +-- | The bytes of a 'PaymentSecret'. +un_payment_secret :: PaymentSecret -> BS.ByteString +un_payment_secret (PaymentSecret bs) = bs +{-# INLINE un_payment_secret #-} + +-- route blinding ------------------------------------------------------------- + +-- | A blinded route (@blinded_path@), as created by a recipient. +data BlindedPath = BlindedPath + { bp_first_node_id :: !BOLT1.Point + -- ^ the (unblinded) introduction node + , bp_first_path_key :: !BOLT1.Point + -- ^ the path key for the introduction node + , bp_hops :: ![BlindedHop] + -- ^ the blinded hops, starting with the introduction node + } deriving (Eq, Show, Generic) --- Common failure codes +instance NFData BlindedPath --- | Invalid realm byte in onion. -pattern InvalidRealm :: FailureCode -pattern InvalidRealm = FailureCode 0x4001 -- PERM .|. 1 +-- | A hop of a blinded route (@blinded_path_hop@). +data BlindedHop = BlindedHop + { bh_blinded_node_id :: !BOLT1.Point + -- ^ the hop's blinded node id + , bh_encrypted_data :: !BS.ByteString + -- ^ the hop's @encrypted_recipient_data@ + } deriving (Eq, Show, Generic) --- | Temporary node failure. -pattern TemporaryNodeFailure :: FailureCode -pattern TemporaryNodeFailure = FailureCode 0x2002 -- NODE .|. 2 +instance NFData BlindedHop --- | Permanent node failure. -pattern PermanentNodeFailure :: FailureCode -pattern PermanentNodeFailure = FailureCode 0x6002 -- PERM .|. NODE .|. 2 +-- | The @encrypted_data_tlv@ stream carried, encrypted, in a hop's +-- @encrypted_recipient_data@. +-- +-- 'bhd_extra' plays the same role as 'hp_extra' in 'HopPayload'. +data BlindedHopData = BlindedHopData + { bhd_padding :: !(Maybe BS.ByteString) + -- ^ type 1: @padding@ + , bhd_short_channel_id :: !(Maybe BOLT1.ShortChannelId) + -- ^ type 2: @short_channel_id@ + , bhd_next_node_id :: !(Maybe BOLT1.Point) + -- ^ type 4: @next_node_id@ + , bhd_path_id :: !(Maybe BS.ByteString) + -- ^ type 6: @path_id@ + , bhd_next_path_key_override :: !(Maybe BOLT1.Point) + -- ^ type 8: @next_path_key_override@ + , bhd_payment_relay :: !(Maybe PaymentRelay) + -- ^ type 10: @payment_relay@ + , bhd_payment_constraints :: !(Maybe PaymentConstraints) + -- ^ type 12: @payment_constraints@ + , bhd_allowed_features :: !(Maybe BOLT9.FeatureVector) + -- ^ type 14: @allowed_features@ + , bhd_extra :: !BOLT1.TlvStream + -- ^ other records + } deriving (Eq, Show, Generic) --- | Required node feature missing. -pattern RequiredNodeFeatureMissing :: FailureCode -pattern RequiredNodeFeatureMissing = FailureCode 0x6003 -- PERM .|. NODE .|. 3 +instance NFData BlindedHopData --- | Invalid onion version. -pattern InvalidOnionVersion :: FailureCode -pattern InvalidOnionVersion = FailureCode 0xC004 -- BADONION .|. PERM .|. 4 +-- | The empty 'BlindedHopData', to be updated with record syntax. +-- +-- >>> let bhd = empty_blinded_hop_data { bhd_path_id = Just "path id" } +-- >>> encode_blinded_hop_data bhd +-- Right "\ACK\apath id" +empty_blinded_hop_data :: BlindedHopData +empty_blinded_hop_data = BlindedHopData + Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing + BOLT1.empty_tlv_stream + +-- | The @payment_relay@ record of 'BlindedHopData'. +data PaymentRelay = PaymentRelay + { pr_cltv_expiry_delta :: {-# UNPACK #-} !Word16 + , pr_fee_proportional_millionths :: {-# UNPACK #-} !Word32 + , pr_fee_base_msat :: {-# UNPACK #-} !Word32 + } deriving (Eq, Show, Generic) --- | Invalid HMAC in onion. -pattern InvalidOnionHmac :: FailureCode -pattern InvalidOnionHmac = FailureCode 0xC005 -- BADONION .|. PERM .|. 5 +instance NFData PaymentRelay --- | Invalid ephemeral key in onion. -pattern InvalidOnionKey :: FailureCode -pattern InvalidOnionKey = FailureCode 0xC006 -- BADONION .|. PERM .|. 6 +-- | The @payment_constraints@ record of 'BlindedHopData'. +data PaymentConstraints = PaymentConstraints + { pc_max_cltv_expiry :: {-# UNPACK #-} !Word32 + , pc_htlc_minimum_msat :: !BOLT1.MilliSatoshi + } deriving (Eq, Show, Generic) --- | Temporary channel failure. -pattern TemporaryChannelFailure :: FailureCode -pattern TemporaryChannelFailure = FailureCode 0x1007 -- UPDATE .|. 7 +instance NFData PaymentConstraints --- | Permanent channel failure. -pattern PermanentChannelFailure :: FailureCode -pattern PermanentChannelFailure = FailureCode 0x4008 -- PERM .|. 8 +-- | What a node in a blinded route learns from its +-- @encrypted_recipient_data@. +data BlindedInfo = BlindedInfo + { bi_data :: !BlindedHopData + -- ^ the decrypted @encrypted_data_tlv@ + , bi_next_path_key :: !BOLT1.Point + -- ^ the @path_key@ to send to the next node + } deriving (Eq, Show, Generic) --- | Amount below minimum for channel. -pattern AmountBelowMinimum :: FailureCode -pattern AmountBelowMinimum = FailureCode 0x100B -- UPDATE .|. 11 +instance NFData BlindedInfo --- | Fee insufficient. -pattern FeeInsufficient :: FailureCode -pattern FeeInsufficient = FailureCode 0x100C -- UPDATE .|. 12 +-- failure messages ----------------------------------------------------------- --- | Incorrect CLTV expiry. -pattern IncorrectCltvExpiry :: FailureCode -pattern IncorrectCltvExpiry = FailureCode 0x100D -- UPDATE .|. 13 +-- | A failure message (@failuremsg@): a failure, followed by an optional +-- TLV stream. +data FailureMessage = FailureMessage + { fm_failure :: !Failure + , fm_tlvs :: !BOLT1.TlvStream + } deriving (Eq, Show, Generic) --- | Expiry too soon. -pattern ExpiryTooSoon :: FailureCode -pattern ExpiryTooSoon = FailureCode 0x100E -- UPDATE .|. 14 +instance NFData FailureMessage --- | Payment details incorrect or unknown. -pattern IncorrectOrUnknownPaymentDetails :: FailureCode -pattern IncorrectOrUnknownPaymentDetails = FailureCode 0x400F -- PERM .|. 15 +-- | A failure: a failure code and its data. +-- +-- A @channel_update@ field holds the raw bytes of the update; it is +-- empty when the update is omitted. +data Failure + = TemporaryNodeFailure + -- ^ NODE|2 + | PermanentNodeFailure + -- ^ PERM|NODE|2 + | RequiredNodeFeatureMissing + -- ^ PERM|NODE|3 + | InvalidOnionVersion !OnionHash + -- ^ BADONION|PERM|4: @sha256_of_onion@ + | InvalidOnionHmac !OnionHash + -- ^ BADONION|PERM|5: @sha256_of_onion@ + | InvalidOnionKey !OnionHash + -- ^ BADONION|PERM|6: @sha256_of_onion@ + | TemporaryChannelFailure !BS.ByteString + -- ^ UPDATE|7: @channel_update@ + | PermanentChannelFailure + -- ^ PERM|8 + | RequiredChannelFeatureMissing + -- ^ PERM|9 + | UnknownNextPeer + -- ^ PERM|10 + | AmountBelowMinimum !BOLT1.MilliSatoshi !BS.ByteString + -- ^ UPDATE|11: @htlc_msat@, @channel_update@ + | FeeInsufficient !BOLT1.MilliSatoshi !BS.ByteString + -- ^ UPDATE|12: @htlc_msat@, @channel_update@ + | IncorrectCltvExpiry {-# UNPACK #-} !Word32 !BS.ByteString + -- ^ UPDATE|13: @cltv_expiry@, @channel_update@ + | ExpiryTooSoon !BS.ByteString + -- ^ UPDATE|14: @channel_update@ + | IncorrectOrUnknownPaymentDetails !BOLT1.MilliSatoshi + {-# UNPACK #-} !Word32 + -- ^ PERM|15: @htlc_msat@, @height@ + | FinalIncorrectCltvExpiry {-# UNPACK #-} !Word32 + -- ^ 18: @cltv_expiry@ + | FinalIncorrectHtlcAmount !BOLT1.MilliSatoshi + -- ^ 19: @incoming_htlc_amt@ + | ChannelDisabled {-# UNPACK #-} !Word16 !BS.ByteString + -- ^ UPDATE|20: @disabled_flags@, @channel_update@ + | ExpiryTooFar + -- ^ 21 + | InvalidOnionPayload !(Maybe (Word64, Word16)) + -- ^ PERM|22: the optional @type@ and @offset@ + | MppTimeout + -- ^ 23 + | InvalidOnionBlinding !OnionHash + -- ^ BADONION|PERM|24: @sha256_of_onion@ + | UnknownFailure {-# UNPACK #-} !Word16 !BS.ByteString + -- ^ a failure code not defined above, and the bytes following it + deriving (Eq, Show, Generic) --- | Final incorrect CLTV expiry. -pattern FinalIncorrectCltvExpiry :: FailureCode -pattern FinalIncorrectCltvExpiry = FailureCode 18 -- 0x12 +instance NFData Failure --- | Final incorrect HTLC amount. -pattern FinalIncorrectHtlcAmount :: FailureCode -pattern FinalIncorrectHtlcAmount = FailureCode 19 -- 0x13 +-- | A 32-byte @sha256_of_onion@. +newtype OnionHash = OnionHash BS.ByteString + deriving (Eq, Show, Generic) --- | Channel disabled. -pattern ChannelDisabled :: FailureCode -pattern ChannelDisabled = FailureCode 0x1014 -- UPDATE .|. 20 +instance NFData OnionHash --- | Expiry too far. -pattern ExpiryTooFar :: FailureCode -pattern ExpiryTooFar = FailureCode 21 -- 0x15 +-- | Construct an 'OnionHash' from exactly 32 bytes. +-- +-- >>> fmap (BS.length . un_onion_hash) (onion_hash (BS.replicate 32 0)) +-- Just 32 +onion_hash :: BS.ByteString -> Maybe OnionHash +onion_hash bs + | BS.length bs == 32 = Just (OnionHash bs) + | otherwise = Nothing +{-# INLINE onion_hash #-} + +-- | The bytes of an 'OnionHash'. +un_onion_hash :: OnionHash -> BS.ByteString +un_onion_hash (OnionHash bs) = bs +{-# INLINE un_onion_hash #-} + +-- | The @failure_code@ of a 'Failure'. +-- +-- >>> failure_code TemporaryNodeFailure +-- 8194 +-- >>> failure_code (UnknownFailure 0x1234 mempty) +-- 4660 +failure_code :: Failure -> Word16 +failure_code f = case f of + TemporaryNodeFailure -> 0x2002 + PermanentNodeFailure -> 0x6002 + RequiredNodeFeatureMissing -> 0x6003 + InvalidOnionVersion _ -> 0xc004 + InvalidOnionHmac _ -> 0xc005 + InvalidOnionKey _ -> 0xc006 + TemporaryChannelFailure _ -> 0x1007 + PermanentChannelFailure -> 0x4008 + RequiredChannelFeatureMissing -> 0x4009 + UnknownNextPeer -> 0x400a + AmountBelowMinimum _ _ -> 0x100b + FeeInsufficient _ _ -> 0x100c + IncorrectCltvExpiry _ _ -> 0x100d + ExpiryTooSoon _ -> 0x100e + IncorrectOrUnknownPaymentDetails _ _ -> 0x400f + FinalIncorrectCltvExpiry _ -> 0x0012 + FinalIncorrectHtlcAmount _ -> 0x0013 + ChannelDisabled _ _ -> 0x1014 + ExpiryTooFar -> 0x0015 + InvalidOnionPayload _ -> 0x4016 + MppTimeout -> 0x0017 + InvalidOnionBlinding _ -> 0xc018 + UnknownFailure c _ -> c + +-- | Is the BADONION flag (0x8000) set: was the onion unparsable? +-- +-- >>> is_badonion TemporaryNodeFailure +-- False +is_badonion :: Failure -> Bool +is_badonion f = failure_code f .&. 0x8000 /= 0 +{-# INLINE is_badonion #-} --- | Invalid onion payload. -pattern InvalidOnionPayload :: FailureCode -pattern InvalidOnionPayload = FailureCode 0x4016 -- PERM .|. 22 +-- | Is the PERM flag (0x4000) set: is the failure permanent? +-- +-- >>> is_perm PermanentNodeFailure +-- True +is_perm :: Failure -> Bool +is_perm f = failure_code f .&. 0x4000 /= 0 +{-# INLINE is_perm #-} --- | MPP timeout. -pattern MppTimeout :: FailureCode -pattern MppTimeout = FailureCode 23 -- 0x17 +-- | Is the NODE flag (0x2000) set: is it a node (not channel) failure? +-- +-- >>> is_node TemporaryNodeFailure +-- True +is_node :: Failure -> Bool +is_node f = failure_code f .&. 0x2000 /= 0 +{-# INLINE is_node #-} + +-- | Is the UPDATE flag (0x1000) set: was a channel forwarding parameter +-- violated? +-- +-- >>> is_update (ExpiryTooSoon mempty) +-- True +is_update :: Failure -> Bool +is_update f = failure_code f .&. 0x1000 /= 0 +{-# INLINE is_update #-} + +-- errors --------------------------------------------------------------------- + +-- | Why a decoder failed. +data DecodeError + = InvalidLength + -- ^ the input has the wrong length + | InvalidPoint + -- ^ a point lacks a compressed-encoding prefix + | InvalidTlvStream !BOLT1.TlvError + -- ^ the TLV stream is malformed, or has an unknown even type + | InvalidTlvValue {-# UNPACK #-} !Word64 + -- ^ the value of the record of this type is malformed + | InvalidFailureData {-# UNPACK #-} !Word16 + -- ^ the data of a failure with this code is malformed + deriving (Eq, Show, Generic) --- Processing results ------------------------------------------------------- +instance NFData DecodeError --- | Result of processing an onion packet. -data ProcessResult - = Forward !ForwardInfo -- ^ Forward to next hop - | Receive !ReceiveInfo -- ^ Final destination reached +-- | Why an encoder failed. +data EncodeError + = ConflictingTlv + -- ^ an extra TLV record has the type of a known record + | FieldTooLong + -- ^ a length-prefixed field exceeds its maximum length deriving (Eq, Show, Generic) --- | Information for forwarding to next hop. -data ForwardInfo = ForwardInfo - { fiNextPacket :: !OnionPacket - , fiPayload :: !HopPayload - , fiSharedSecret :: !SharedSecret -- ^ For error attribution - } deriving (Eq, Show, Generic) - --- | Information for receiving at final destination. -data ReceiveInfo = ReceiveInfo - { riPayload :: !HopPayload - , riSharedSecret :: !SharedSecret - } deriving (Eq, Show, Generic) +instance NFData EncodeError + +-- | Why an onion packet was rejected. +data ProcessError + = InvalidVersion {-# UNPACK #-} !Word8 + -- ^ the version byte is not 0 + | InvalidPublicKey + -- ^ the ephemeral public key is not a valid point + | HmacMismatch + -- ^ the packet's HMAC is wrong + | InvalidPayloadLength + -- ^ the payload length is malformed, below 2, or past the end of + -- @hop_payloads@ + | InvalidPayload !DecodeError + -- ^ the payload is not a valid @payload@ TLV stream + | InvalidPathKey + -- ^ the path key is not a valid point + | UnexpectedPathKey + -- ^ a path key was given with no @encrypted_recipient_data@, or + -- both a @path_key@ and a @current_path_key@ were given + | MissingPathKey + -- ^ @encrypted_recipient_data@ was given with no path key + | InvalidRecipientData + -- ^ the @encrypted_recipient_data@ doesn't decrypt to a valid + -- @encrypted_data_tlv@ stream + deriving (Eq, Show, Generic) --- Constants ---------------------------------------------------------------- - --- | Total onion packet size (1366 bytes). -onionPacketSize :: Int -onionPacketSize = 1366 -{-# INLINE onionPacketSize #-} - --- | Hop payloads section size (1300 bytes). -hopPayloadsSize :: Int -hopPayloadsSize = 1300 -{-# INLINE hopPayloadsSize #-} - --- | HMAC size (32 bytes). -hmacSize :: Int -hmacSize = 32 -{-# INLINE hmacSize #-} - --- | Compressed public key size (33 bytes). -pubkeySize :: Int -pubkeySize = 33 -{-# INLINE pubkeySize #-} - --- | Version byte for onion packets. -versionByte :: Word8 -versionByte = 0x00 -{-# INLINE versionByte #-} - --- | Maximum payload size (1300 - 32 - 1 = 1267 bytes). -maxPayloadSize :: Int -maxPayloadSize = hopPayloadsSize - hmacSize - 1 -{-# INLINE maxPayloadSize #-} - --- Silence unused import warning -_useBits :: Word16 -_useBits = BADONION .&. PERM .|. NODE .|. UPDATE +instance NFData ProcessError diff --git a/ppad-bolt4.cabal b/ppad-bolt4.cabal @@ -1,18 +1,20 @@ cabal-version: 3.0 name: ppad-bolt4 -version: 0.0.1 -synopsis: BOLT4 (onion routing) for Lightning Network +version: 0.1.0 +synopsis: Onion routing per BOLT #4 license: MIT license-file: LICENSE author: Jared Tobin maintainer: jared@ppad.tech category: Cryptography build-type: Simple -tested-with: GHC == 9.10.3 +tested-with: GHC == { 9.10.3 } extra-doc-files: CHANGELOG description: - A pure Haskell implementation of BOLT4 (onion routing) from - the Lightning Network protocol specification. + Onion routing for the Lightning Network, per BOLT #4 + (<https://github.com/lightning/bolts/blob/master/04-onion-routing.md>): + onion construction and processing, returning errors, and route + blinding. source-repository head type: git @@ -22,26 +24,28 @@ library default-language: Haskell2010 hs-source-dirs: lib ghc-options: - -Wall + -Wall exposed-modules: - Lightning.Protocol.BOLT4 - Lightning.Protocol.BOLT4.Blinding - Lightning.Protocol.BOLT4.Codec - Lightning.Protocol.BOLT4.Construct - Lightning.Protocol.BOLT4.Error - Lightning.Protocol.BOLT4.Internal - Lightning.Protocol.BOLT4.Prim - Lightning.Protocol.BOLT4.Process - Lightning.Protocol.BOLT4.Types + Lightning.Protocol.BOLT4 + other-modules: + Lightning.Protocol.BOLT4.Blinding + Lightning.Protocol.BOLT4.Codec + Lightning.Protocol.BOLT4.Construct + Lightning.Protocol.BOLT4.Error + Lightning.Protocol.BOLT4.Prim + Lightning.Protocol.BOLT4.Process + Lightning.Protocol.BOLT4.Types build-depends: base >= 4.9 && < 5 - , bytestring >= 0.9 && < 0.13 - , ppad-aead >= 0.3 && < 0.4 - , ppad-bolt1 >= 0.0.1 && < 0.1 - , ppad-chacha >= 0.2 && < 0.3 + , bytestring >= 0.11.1 && < 0.13 + , deepseq >= 1.4 && < 1.6 + , ppad-aead >= 0.3.6 && < 0.4 + , ppad-bolt1 >= 0.1 && < 0.2 + , ppad-bolt9 >= 0.1 && < 0.2 + , ppad-chacha >= 0.3 && < 0.4 , ppad-fixed >= 0.2 && < 0.3 - , ppad-secp256k1 >= 0.5 && < 0.6 - , ppad-sha256 >= 0.3 && < 0.4 + , ppad-secp256k1 >= 0.5.8 && < 0.6 + , ppad-sha256 >= 0.3.2 && < 0.4 test-suite bolt4-tests type: exitcode-stdio-1.0 @@ -50,12 +54,15 @@ test-suite bolt4-tests main-is: Main.hs other-modules: Vectors ghc-options: - -rtsopts -Wall -O2 + -rtsopts -Wall build-depends: base , bytestring , ppad-base16 + , ppad-bolt1 , ppad-bolt4 + , ppad-bolt9 + , ppad-chacha , ppad-secp256k1 , ppad-sha256 , tasty @@ -68,12 +75,13 @@ benchmark bolt4-bench hs-source-dirs: bench main-is: Main.hs ghc-options: - -rtsopts -O2 -Wall -fno-warn-orphans + -rtsopts -O2 -Wall build-depends: base , bytestring , criterion , deepseq + , ppad-bolt1 , ppad-bolt4 benchmark bolt4-weigh @@ -82,9 +90,11 @@ benchmark bolt4-weigh hs-source-dirs: bench main-is: Weight.hs ghc-options: - -rtsopts -O2 -Wall -fno-warn-orphans + -rtsopts -O2 -Wall build-depends: base , bytestring + , deepseq + , ppad-bolt1 , ppad-bolt4 , weigh