bolt8

Encrypted and authenticated transport, per BOLT #8 (docs.ppad.tech/bolt8).
git clone git://git.ppad.tech/bolt8.git
Log | Files | Refs | README | LICENSE

commit 4b104d803b2655047c7a66bad50254e7100e06b8
parent b8f9b66519630692847aa1fdcf4e0b0c1249a677
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat, 10 Oct 2026 13:15:16 +0400

Merge branch 'fix/misc'

Bug fixes ahead of the 0.1.0 modernization:

- docs: keys rotate every 1000 nonces, i.e. every 500 messages per
  direction (the module header and decrypt_frame said every 1000
  messages). The framing guidance in the module header and README
  named 'decrypt_frame' as the exact-frame function; it is 'decrypt'.
- lib: step_nonce rotates when the incremented nonce is >= 1000
  rather than == 1000, so a session with a nonce already past 1000
  rotates instead of growing its nonce without bound. Regression
  tests cover both directions.
- meta: require ppad-aead >= 0.3.6 for its constant-time MAC
  comparison fixes.
- flake: lock ppad-aead 0.3.6. This forces ppad-fixed 0.2 via
  ppad-poly1305, so ppad-secp256k1 is also bumped to 0.5.8 (ppad-fixed
  0.2) to keep the cabal plan solvable.

Diffstat:
MREADME.md | 8+++++---
Mflake.lock | 48++++++++++++++++++++++++------------------------
Mlib/Lightning/Protocol/BOLT8.hs | 13++++++++-----
Mlib/Lightning/Protocol/BOLT8/Internal.hs | 4++--
Mppad-bolt8.cabal | 2+-
Mtest/Main.hs | 41+++++++++++++++++++++++++++++++++++++++++
6 files changed, 81 insertions(+), 35 deletions(-)

diff --git a/README.md b/README.md @@ -40,9 +40,11 @@ A sample GHCi session: ## Framing -On a byte stream, use `decrypt_frame` when you have an exact frame, or -`decrypt_frame_partial` to work incrementally and learn how many bytes -are still required for the next step. +On a byte stream, use `decrypt` when you have exactly one frame, +`decrypt_frame` when a buffer starts with a complete frame (it returns +the unconsumed remainder), or `decrypt_frame_partial` to work +incrementally and learn how many bytes are still required for the next +step. ## Documentation diff --git a/flake.lock b/flake.lock @@ -54,11 +54,11 @@ "ppad-poly1305": "ppad-poly1305" }, "locked": { - "lastModified": 1783709438, - "narHash": "sha256-6ED7O4an17TYT6/FLBJUdJP8u+kLv7qVfyp0Ik7x0o8=", + "lastModified": 1788706042, + "narHash": "sha256-+N5XTJmW3JMCfrSvGYg+G02VVW8DWFdbk9gTc9991x4=", "ref": "master", - "rev": "de6580948feb41bcea864ffb3ff770bdd2ea4dd9", - "revCount": 33, + "rev": "00b03f80d8d71f802061a8af9d041c6891b87660", + "revCount": 44, "type": "git", "url": "git://git.ppad.tech/aead.git" }, @@ -259,11 +259,11 @@ ] }, "locked": { - "lastModified": 1783709359, - "narHash": "sha256-Xl31mWwQfJcfztw5v1NeaUlYn2vAaSVcambpCeemT7I=", + "lastModified": 1788702071, + "narHash": "sha256-B0a9eLZBsHTrsr/SNyPhpiq1NzjvTFsb1Kdr1mRmxXM=", "ref": "master", - "rev": "2beeb81459bc33ad074321e9ced4ba3880edf747", - "revCount": 36, + "rev": "46cb61870a875f2c4e1633bc4d2cba1ddffa461d", + "revCount": 41, "type": "git", "url": "git://git.ppad.tech/chacha.git" }, @@ -296,11 +296,11 @@ ] }, "locked": { - "lastModified": 1780868194, - "narHash": "sha256-Yd3J3gCNLE2vZVMMg1fD7aVpPySIbFqxtwskU6nD/mc=", + "lastModified": 1785603497, + "narHash": "sha256-rF8KdVJ+VwUT2bEfCLZMPc05rUclr0wN2uOTYvd3z7g=", "ref": "master", - "rev": "74ebe44f818353129c5ec973a55c8bc487e2fd7b", - "revCount": 297, + "rev": "e303bda7578385f488c2f23baa2e1a6a2b0b2aaf", + "revCount": 305, "type": "git", "url": "git://git.ppad.tech/fixed.git" }, @@ -330,11 +330,11 @@ ] }, "locked": { - "lastModified": 1780868194, - "narHash": "sha256-Yd3J3gCNLE2vZVMMg1fD7aVpPySIbFqxtwskU6nD/mc=", + "lastModified": 1785603497, + "narHash": "sha256-rF8KdVJ+VwUT2bEfCLZMPc05rUclr0wN2uOTYvd3z7g=", "ref": "master", - "rev": "74ebe44f818353129c5ec973a55c8bc487e2fd7b", - "revCount": 297, + "rev": "e303bda7578385f488c2f23baa2e1a6a2b0b2aaf", + "revCount": 305, "type": "git", "url": "git://git.ppad.tech/fixed.git" }, @@ -470,11 +470,11 @@ ] }, "locked": { - "lastModified": 1783709370, - "narHash": "sha256-OyDev7A0Q7SqEyAztCeXcl+DILaKKYIQg2h2+8jaB9k=", + "lastModified": 1785621286, + "narHash": "sha256-5Ft6ejGg9kIXGtgWzvq+YXh0r7sQBwA7hPnwDqvjKbo=", "ref": "master", - "rev": "d7cfef78410784591efe93f0e3322278029c41ed", - "revCount": 45, + "rev": "173e478290ecf9d639f0c85ae625da474a62a6b2", + "revCount": 48, "type": "git", "url": "git://git.ppad.tech/poly1305.git" }, @@ -508,11 +508,11 @@ "ppad-sha512": "ppad-sha512_2" }, "locked": { - "lastModified": 1783709454, - "narHash": "sha256-6BknM38fe2vFwWWRsBeebTt+hKwaZL0ib89XqPO6Y6U=", + "lastModified": 1785605654, + "narHash": "sha256-VDNnKXmJIZZ/3u7Tf1rK93GR4x7OiAadqykhqCwkHb4=", "ref": "master", - "rev": "bc0d3792bcea0475ed2e4879747dbd0e0e6db3e7", - "revCount": 266, + "rev": "fdb80d54b86c61bd1a15e2e8db351091d02c89ee", + "revCount": 280, "type": "git", "url": "git://git.ppad.tech/secp256k1.git" }, diff --git a/lib/Lightning/Protocol/BOLT8.hs b/lib/Lightning/Protocol/BOLT8.hs @@ -46,7 +46,8 @@ -- After a successful handshake, use 'encrypt' and 'decrypt' -- to exchange messages. Each returns an updated 'Session' -- that must be used for the next operation (keys rotate --- every 1000 messages): +-- every 1000 nonces, i.e. every 500 messages in each +-- direction): -- -- @ -- -- sender @@ -59,10 +60,12 @@ -- = Message Framing -- -- BOLT #8 runs over a byte stream, so callers often need to --- deal with partial buffers. Use 'decrypt_frame' when you --- have exactly one frame, or 'decrypt_frame_partial' to --- handle incremental reads and return how many bytes are --- still needed. +-- deal with partial buffers. Use 'decrypt' when you have +-- exactly one frame, 'decrypt_frame' when a buffer starts +-- with a complete frame (it returns the unconsumed +-- remainder), or 'decrypt_frame_partial' to handle +-- incremental reads and return how many bytes are still +-- needed. -- -- Maximum plaintext size is 65535 bytes. diff --git a/lib/Lightning/Protocol/BOLT8/Internal.hs b/lib/Lightning/Protocol/BOLT8/Internal.hs @@ -704,7 +704,7 @@ decrypt sess packet = do -- -- Returns the plaintext, any unconsumed bytes, and the -- updated session. Key rotation is handled automatically --- every 1000 messages. +-- every 1000 nonces (i.e. every 500 messages). -- -- This is useful for streaming scenarios where multiple -- messages may be buffered together. The remainder can be @@ -827,7 +827,7 @@ step_nonce -> BS.ByteString -> (Word64, BS.ByteString, BS.ByteString) step_nonce n ck k - | n + 1 == 1000 = + | n + 1 >= 1000 = let !(ck', k') = mix_key ck k in (0, ck', k') | otherwise = (n + 1, ck, k) diff --git a/ppad-bolt8.cabal b/ppad-bolt8.cabal @@ -36,7 +36,7 @@ library build-depends: base >= 4.9 && < 5 , bytestring >= 0.9 && < 0.13 - , ppad-aead >= 0.3 && < 0.4 + , ppad-aead >= 0.3.6 && < 0.4 , ppad-hkdf >= 0.3.2 && < 0.4 , ppad-secp256k1 >= 0.5.3 && < 0.6 , ppad-sha256 >= 0.3 && < 0.4 diff --git a/test/Main.hs b/test/Main.hs @@ -7,6 +7,7 @@ import Data.Bits (xor) import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 import qualified Lightning.Protocol.BOLT8 as BOLT8 +import qualified Lightning.Protocol.BOLT8.Internal as I import Test.Tasty import Test.Tasty.HUnit import Test.Tasty.QuickCheck (Gen, Property, choose, forAll, @@ -34,6 +35,7 @@ main = defaultMain $ testGroup "ppad-bolt8" [ , framing_tests , partial_framing_tests , negative_tests + , rotation_tests , property_tests ] @@ -542,6 +544,45 @@ flip_byte i bs b = BS.index post 0 in pure (pre <> BS.cons (b `xor` 0xff) (BS.drop 1 post)) +-- rotation tests ------------------------------------------------------------ + +rotation_tests :: TestTree +rotation_tests = testGroup "Key Rotation" [ + testCase "send key rotates when nonce is past 1000" + test_rotate_past_1000_send + , testCase "receive key rotates when nonce is past 1000" + test_rotate_past_1000_recv + ] + +-- A session whose nonces are already past 1000 must still rotate on +-- the next step rather than letting the nonce grow without bound. +test_rotate_past_1000_send :: Assertion +test_rotate_past_1000_send = do + sess <- get_initiator_session + let stale = sess { I.sess_sn = I.SessionNonce 1001 } + (_, sess') <- expectRight "encrypt" (BOLT8.encrypt stale hello) + I.unSessionNonce (I.sess_sn sess') @?= 1 + assertBool "send key rotated" (I.sess_sk sess' /= I.sess_sk sess) + assertBool "send ck rotated" (I.sess_sck sess' /= I.sess_sck sess) + +test_rotate_past_1000_recv :: Assertion +test_rotate_past_1000_recv = do + i_sess <- get_initiator_session + let i_stale = i_sess { I.sess_sn = I.SessionNonce 1001 } + r_stale = I.Session { + I.sess_sk = I.sess_rk i_sess + , I.sess_sn = I.SessionNonce 0 + , I.sess_sck = I.sess_rck i_sess + , I.sess_rk = I.sess_sk i_sess + , I.sess_rn = I.SessionNonce 1001 + , I.sess_rck = I.sess_sck i_sess + } + (ct, _) <- expectRight "encrypt" (BOLT8.encrypt i_stale hello) + (pt, r_sess') <- expectRight "decrypt" (BOLT8.decrypt r_stale ct) + pt @?= hello + I.unSessionNonce (I.sess_rn r_sess') @?= 1 + assertBool "recv key rotated" (I.sess_rk r_sess' /= I.sess_rk r_stale) + -- utilities ----------------------------------------------------------------- -- Safe hex decode for test vectors (only called at top level with known-good