bolt3

Lightning transaction and script formats, per BOLT #3.
git clone git://git.ppad.tech/bolt3.git
Log | Files | Refs | README | LICENSE

commit 23add542ee6973a0714d712dce912113c7310d4f
parent 8af91e37d272af25626b1f02b2cdf55456d4db36
Author: Jared Tobin <jared@jtobin.io>
Date:   Sun, 25 Jan 2026 11:05:41 +0400

Merge branch 'impl/keys'

Diffstat:
Mflake.nix | 18+++++++++++++++++-
Mlib/Lightning/Protocol/BOLT3/Keys.hs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mppad-bolt3.cabal | 2++
3 files changed, 419 insertions(+), 20 deletions(-)

diff --git a/flake.nix b/flake.nix @@ -7,11 +7,22 @@ url = "git://git.ppad.tech/nixpkgs.git"; ref = "master"; }; + ppad-sha256 = { + type = "git"; + url = "git://git.ppad.tech/sha256.git"; + ref = "master"; + }; + ppad-secp256k1 = { + type = "git"; + url = "git://git.ppad.tech/secp256k1.git"; + ref = "master"; + }; flake-utils.follows = "ppad-nixpkgs/flake-utils"; nixpkgs.follows = "ppad-nixpkgs/nixpkgs"; }; - outputs = { self, nixpkgs, flake-utils, ppad-nixpkgs }: + outputs = { self, nixpkgs, flake-utils, ppad-nixpkgs + , ppad-sha256, ppad-secp256k1 }: flake-utils.lib.eachDefaultSystem (system: let lib = "ppad-bolt3"; @@ -21,7 +32,12 @@ llvm = pkgs.llvmPackages_19.llvm; clang = pkgs.llvmPackages_19.clang; + sha256 = ppad-sha256.packages.${system}.default; + secp256k1 = ppad-secp256k1.packages.${system}.default; + hpkgs = pkgs.haskell.packages.ghc910.extend (new: old: { + ppad-sha256 = sha256; + ppad-secp256k1 = secp256k1; ${lib} = new.callCabal2nix lib ./. { }; }); diff --git a/lib/Lightning/Protocol/BOLT3/Keys.hs b/lib/Lightning/Protocol/BOLT3/Keys.hs @@ -1,5 +1,6 @@ {-# OPTIONS_HADDOCK prune #-} {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} -- | -- Module: Lightning.Protocol.BOLT3.Keys @@ -13,39 +14,419 @@ -- -- @ -- pubkey = basepoint + SHA256(per_commitment_point || basepoint) * G --- revocationpubkey = revocation_basepoint * SHA256(revocation_basepoint || per_commitment_point) --- + per_commitment_point * SHA256(per_commitment_point || revocation_basepoint) +-- revocationpubkey = revocation_basepoint * SHA256(revocation_basepoint +-- || per_commitment_point) +-- + per_commitment_point * SHA256(per_commitment_point +-- || revocation_basepoint) -- @ module Lightning.Protocol.BOLT3.Keys ( -- * Per-commitment point derivation - -- derive_per_commitment_point + derive_per_commitment_point -- * Key derivation - -- , derive_pubkey - -- , derive_localpubkey - -- , derive_local_htlcpubkey - -- , derive_remote_htlcpubkey - -- , derive_local_delayedpubkey - -- , derive_remote_delayedpubkey + , derive_pubkey + , derive_localpubkey + , derive_local_htlcpubkey + , derive_remote_htlcpubkey + , derive_local_delayedpubkey + , derive_remote_delayedpubkey -- * Revocation key derivation - -- , derive_revocationpubkey - -- , derive_revocationprivkey + , derive_revocationpubkey -- * Per-commitment secret generation - -- , generate_from_seed - -- , derive_secret + , generate_from_seed + , derive_secret -- * Per-commitment secret storage - -- , SecretStore - -- , empty_store - -- , insert_secret - -- , derive_old_secret + , SecretStore + , empty_store + , insert_secret + , derive_old_secret -- * Commitment number obscuring - -- , obscured_commitment_number - -- , commitment_number_from_locktime_sequence + , obscured_commitment_number ) where +import Data.Bits ((.&.), xor, shiftL, shiftR, testBit, complementBit) +import qualified Data.ByteString as BS +import Data.Word (Word64) +import GHC.Generics (Generic) +import qualified Crypto.Curve.Secp256k1 as S +import qualified Crypto.Hash.SHA256 as SHA256 import Lightning.Protocol.BOLT3.Types + +-- Per-commitment point derivation ---------------------------------------- + +-- | Derive the per-commitment point from a per-commitment secret. +-- +-- @per_commitment_point = per_commitment_secret * G@ +-- +-- >>> let secret = PerCommitmentSecret (BS.replicate 32 0x01) +-- >>> derive_per_commitment_point secret +-- Just (PerCommitmentPoint ...) +derive_per_commitment_point + :: PerCommitmentSecret + -> Maybe PerCommitmentPoint +derive_per_commitment_point (PerCommitmentSecret sec) = do + sk <- S.parse_seckey sec + let !pk = S.derive_pubkey sk + !bs = S.serialize_point pk + pure $! PerCommitmentPoint (Point bs) +{-# INLINE derive_per_commitment_point #-} + +-- Key derivation --------------------------------------------------------- + +-- | Derive a pubkey from a basepoint and per-commitment point. +-- +-- @pubkey = basepoint + SHA256(per_commitment_point || basepoint) * G@ +-- +-- This is the general derivation formula used for localpubkey, +-- local_htlcpubkey, remote_htlcpubkey, local_delayedpubkey, and +-- remote_delayedpubkey. +-- +-- >>> derive_pubkey basepoint per_commitment_point +-- Just (Pubkey ...) +derive_pubkey + :: Point -- ^ basepoint + -> PerCommitmentPoint -- ^ per_commitment_point + -> Maybe Pubkey +derive_pubkey (Point basepointBs) (PerCommitmentPoint (Point pcpBs)) = do + basepoint <- S.parse_point basepointBs + pcp <- S.parse_point pcpBs + -- SHA256(per_commitment_point || basepoint) + let !h = SHA256.hash (pcpBs <> basepointBs) + -- Treat hash as scalar and multiply by G + tweak <- S.parse_seckey h + let !tweakPoint = S.derive_pubkey tweak + -- Add basepoint + tweak*G + result <- S.add_point basepoint tweakPoint + let !bs = S.serialize_point result + pure $! Pubkey bs +{-# INLINE derive_pubkey #-} + +-- | Derive localpubkey from payment_basepoint and per_commitment_point. +-- +-- >>> derive_localpubkey payment_basepoint per_commitment_point +-- Just (LocalPubkey ...) +derive_localpubkey + :: PaymentBasepoint + -> PerCommitmentPoint + -> Maybe LocalPubkey +derive_localpubkey (PaymentBasepoint pt) pcp = + LocalPubkey <$> derive_pubkey pt pcp +{-# INLINE derive_localpubkey #-} + +-- | Derive local_htlcpubkey from htlc_basepoint and per_commitment_point. +-- +-- >>> derive_local_htlcpubkey htlc_basepoint per_commitment_point +-- Just (LocalHtlcPubkey ...) +derive_local_htlcpubkey + :: HtlcBasepoint + -> PerCommitmentPoint + -> Maybe LocalHtlcPubkey +derive_local_htlcpubkey (HtlcBasepoint pt) pcp = + LocalHtlcPubkey <$> derive_pubkey pt pcp +{-# INLINE derive_local_htlcpubkey #-} + +-- | Derive remote_htlcpubkey from htlc_basepoint and per_commitment_point. +-- +-- >>> derive_remote_htlcpubkey htlc_basepoint per_commitment_point +-- Just (RemoteHtlcPubkey ...) +derive_remote_htlcpubkey + :: HtlcBasepoint + -> PerCommitmentPoint + -> Maybe RemoteHtlcPubkey +derive_remote_htlcpubkey (HtlcBasepoint pt) pcp = + RemoteHtlcPubkey <$> derive_pubkey pt pcp +{-# INLINE derive_remote_htlcpubkey #-} + +-- | Derive local_delayedpubkey from delayed_payment_basepoint and +-- per_commitment_point. +-- +-- >>> derive_local_delayedpubkey delayed_payment_basepoint per_commitment_point +-- Just (LocalDelayedPubkey ...) +derive_local_delayedpubkey + :: DelayedPaymentBasepoint + -> PerCommitmentPoint + -> Maybe LocalDelayedPubkey +derive_local_delayedpubkey (DelayedPaymentBasepoint pt) pcp = + LocalDelayedPubkey <$> derive_pubkey pt pcp +{-# INLINE derive_local_delayedpubkey #-} + +-- | Derive remote_delayedpubkey from delayed_payment_basepoint and +-- per_commitment_point. +-- +-- >>> derive_remote_delayedpubkey delayed_payment_basepoint pcp +-- Just (RemoteDelayedPubkey ...) +derive_remote_delayedpubkey + :: DelayedPaymentBasepoint + -> PerCommitmentPoint + -> Maybe RemoteDelayedPubkey +derive_remote_delayedpubkey (DelayedPaymentBasepoint pt) pcp = + RemoteDelayedPubkey <$> derive_pubkey pt pcp +{-# INLINE derive_remote_delayedpubkey #-} + +-- Revocation key derivation ---------------------------------------------- + +-- | Derive revocationpubkey from revocation_basepoint and +-- per_commitment_point. +-- +-- @ +-- revocationpubkey = revocation_basepoint +-- * SHA256(revocation_basepoint || per_commitment_point) +-- + per_commitment_point +-- * SHA256(per_commitment_point || revocation_basepoint) +-- @ +-- +-- >>> derive_revocationpubkey revocation_basepoint per_commitment_point +-- Just (RevocationPubkey ...) +derive_revocationpubkey + :: RevocationBasepoint + -> PerCommitmentPoint + -> Maybe RevocationPubkey +derive_revocationpubkey + (RevocationBasepoint (Point rbpBs)) + (PerCommitmentPoint (Point pcpBs)) = do + rbp <- S.parse_point rbpBs + pcp <- S.parse_point pcpBs + -- SHA256(revocation_basepoint || per_commitment_point) + let !h1 = SHA256.hash (rbpBs <> pcpBs) + -- SHA256(per_commitment_point || revocation_basepoint) + let !h2 = SHA256.hash (pcpBs <> rbpBs) + -- Multiply points by their respective scalars + s1 <- S.parse_seckey h1 + s2 <- S.parse_seckey h2 + let !p1 = S.mul_point rbp s1 -- revocation_basepoint * h1 + !p2 = S.mul_point pcp s2 -- per_commitment_point * h2 + -- Add the two points + result <- S.add_point p1 p2 + let !bs = S.serialize_point result + pure $! RevocationPubkey (Pubkey bs) +{-# INLINE derive_revocationpubkey #-} + +-- Per-commitment secret generation --------------------------------------- + +-- | Generate the I'th per-commitment secret from a seed. +-- +-- Implements the generate_from_seed algorithm from BOLT #3: +-- +-- @ +-- generate_from_seed(seed, I): +-- P = seed +-- for B in 47 down to 0: +-- if B set in I: +-- flip(B) in P +-- P = SHA256(P) +-- return P +-- @ +-- +-- >>> generate_from_seed seed 281474976710655 +-- <32-byte secret> +generate_from_seed + :: BS.ByteString -- ^ seed (32 bytes) + -> Word64 -- ^ index I (max 2^48 - 1) + -> BS.ByteString -- ^ per-commitment secret (32 bytes) +generate_from_seed seed idx = go 47 seed where + go :: Int -> BS.ByteString -> BS.ByteString + go !b !p + | b < 0 = p + | testBit idx b = + let !p' = flip_bit b p + !p'' = SHA256.hash p' + in go (b - 1) p'' + | otherwise = go (b - 1) p +{-# INLINE generate_from_seed #-} + +-- | Derive a secret from a base secret. +-- +-- This is a generalization of generate_from_seed used for efficient +-- secret storage. Given a base secret whose index has bits..47 the same +-- as target index I, derive the I'th secret. +-- +-- @ +-- derive_secret(base, bits, I): +-- P = base +-- for B in bits - 1 down to 0: +-- if B set in I: +-- flip(B) in P +-- P = SHA256(P) +-- return P +-- @ +derive_secret + :: BS.ByteString -- ^ base secret + -> Int -- ^ bits (number of trailing bits to process) + -> Word64 -- ^ target index I + -> BS.ByteString -- ^ derived secret +derive_secret base bits idx = go (bits - 1) base where + go :: Int -> BS.ByteString -> BS.ByteString + go !b !p + | b < 0 = p + | testBit idx b = + let !p' = flip_bit b p + !p'' = SHA256.hash p' + in go (b - 1) p'' + | otherwise = go (b - 1) p +{-# INLINE derive_secret #-} + +-- | Flip bit B in a 32-byte bytestring. +-- +-- "flip(B)" alternates the (B mod 8) bit of the (B div 8) byte. +flip_bit :: Int -> BS.ByteString -> BS.ByteString +flip_bit b bs = + let !byteIdx = b `div` 8 + !bitIdx = b `mod` 8 + !len = BS.length bs + in if byteIdx >= len + then bs + else + let !prefix = BS.take byteIdx bs + !byte = BS.index bs byteIdx + !byte' = complementBit byte bitIdx + !suffix = BS.drop (byteIdx + 1) bs + in prefix <> BS.singleton byte' <> suffix +{-# INLINE flip_bit #-} + +-- Per-commitment secret storage ------------------------------------------ + +-- | Entry in the secret store: (index, secret). +data SecretEntry = SecretEntry + { se_index :: {-# UNPACK #-} !Word64 + , se_secret :: !BS.ByteString + } deriving (Eq, Show, Generic) + +-- | Compact storage for per-commitment secrets. +-- +-- Stores up to 49 (value, index) pairs, allowing efficient derivation +-- of any previously-received secret. This is possible because for a +-- given secret on a 2^X boundary, all secrets up to the next 2^X +-- boundary can be derived from it. +newtype SecretStore = SecretStore { unSecretStore :: [SecretEntry] } + deriving (Eq, Show, Generic) + +-- | Empty secret store. +empty_store :: SecretStore +empty_store = SecretStore [] +{-# INLINE empty_store #-} + +-- | Determine which bucket to store a secret in based on its index. +-- +-- Counts trailing zeros in the index. Returns 0-47 for normal indices, +-- or 48 if index is 0 (the seed). +where_to_put_secret :: Word64 -> Int +where_to_put_secret idx = go 0 where + go !b + | b > 47 = 48 -- index 0, this is the seed + | testBit idx b = b + | otherwise = go (b + 1) +{-# INLINE where_to_put_secret #-} + +-- | Insert a secret into the store, validating against existing secrets. +-- +-- Returns Nothing if the secret doesn't derive correctly from known +-- secrets (indicating the secrets weren't generated from the same seed). +-- +-- >>> insert_secret secret 281474976710655 empty_store +-- Just (SecretStore ...) +insert_secret + :: BS.ByteString -- ^ secret (32 bytes) + -> Word64 -- ^ index + -> SecretStore -- ^ current store + -> Maybe SecretStore +insert_secret secret idx (SecretStore known) = do + let !bucket = where_to_put_secret idx + -- Validate: for each bucket 0..bucket-1, check derivation + validated <- validateBuckets bucket known + if validated + then + -- Insert at bucket position, removing any existing entry at + -- same or higher bucket + let !known' = insertAt bucket (SecretEntry idx secret) known + in pure $! SecretStore known' + else Nothing + where + validateBuckets :: Int -> [SecretEntry] -> Maybe Bool + validateBuckets b entries = go 0 entries where + go !currentB [] = Just True + go !currentB (SecretEntry knownIdx knownSecret : rest) + | currentB >= b = Just True + | otherwise = + -- Check if we can derive the known secret from the new one + let !derived = derive_secret secret b knownIdx + in if derived == knownSecret + then go (currentB + 1) rest + else Nothing + + insertAt :: Int -> SecretEntry -> [SecretEntry] -> [SecretEntry] + insertAt b entry [] = [entry] + insertAt b entry entries@(e:es) + | length entries <= b = entries ++ [entry] + | otherwise = take b entries ++ [entry] +{-# INLINE insert_secret #-} + +-- | Derive a previously-received secret from the store. +-- +-- Iterates over known secrets to find one whose index is a prefix of +-- the target index, then derives the target secret from it. +-- +-- >>> derive_old_secret 281474976710654 store +-- Just <32-byte secret> +derive_old_secret + :: Word64 -- ^ target index + -> SecretStore -- ^ store + -> Maybe BS.ByteString +derive_old_secret targetIdx (SecretStore known) = go 0 known where + go :: Int -> [SecretEntry] -> Maybe BS.ByteString + go !b [] = Nothing + go !b (SecretEntry knownIdx knownSecret : rest) = + -- Mask off the non-zero prefix of the index + let !mask = complement ((1 `shiftL` b) - 1) + in if (targetIdx .&. mask) == knownIdx + then Just $! derive_secret knownSecret b targetIdx + else go (b + 1) rest + + complement :: Word64 -> Word64 + complement x = x `xor` 0xFFFFFFFFFFFFFFFF +{-# INLINE derive_old_secret #-} + +-- Commitment number obscuring -------------------------------------------- + +-- | Calculate the obscured commitment number. +-- +-- The 48-bit commitment number is obscured by XOR with the lower 48 bits +-- of SHA256(payment_basepoint from open_channel +-- || payment_basepoint from accept_channel). +-- +-- >>> obscured_commitment_number local_payment_bp remote_payment_bp cn +-- <obscured value> +obscured_commitment_number + :: PaymentBasepoint -- ^ opener's payment_basepoint + -> PaymentBasepoint -- ^ accepter's payment_basepoint + -> CommitmentNumber -- ^ commitment number (48-bit) + -> Word64 -- ^ obscured commitment number +obscured_commitment_number + (PaymentBasepoint (Point openerBs)) + (PaymentBasepoint (Point accepterBs)) + (CommitmentNumber cn) = + let !h = SHA256.hash (openerBs <> accepterBs) + -- Extract lower 48 bits (6 bytes) from the hash + !lower48 = extractLower48 h + -- Mask commitment number to 48 bits + !cn48 = cn .&. 0xFFFFFFFFFFFF + in cn48 `xor` lower48 +{-# INLINE obscured_commitment_number #-} + +-- | Extract lower 48 bits from a 32-byte hash. +-- +-- Takes bytes 26-31 (last 6 bytes) and interprets as big-endian Word64. +extractLower48 :: BS.ByteString -> Word64 +extractLower48 h = + let !b0 = fromIntegral (BS.index h 26) `shiftL` 40 + !b1 = fromIntegral (BS.index h 27) `shiftL` 32 + !b2 = fromIntegral (BS.index h 28) `shiftL` 24 + !b3 = fromIntegral (BS.index h 29) `shiftL` 16 + !b4 = fromIntegral (BS.index h 30) `shiftL` 8 + !b5 = fromIntegral (BS.index h 31) + in b0 + b1 + b2 + b3 + b4 + b5 +{-# INLINE extractLower48 #-} diff --git a/ppad-bolt3.cabal b/ppad-bolt3.cabal @@ -35,6 +35,8 @@ library build-depends: base >= 4.9 && < 5 , bytestring >= 0.9 && < 0.13 + , ppad-secp256k1 + , ppad-sha256 test-suite bolt3-tests type: exitcode-stdio-1.0