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:
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