Add fuzz tests for onion, route blinding and lightning message codecs (#3282)
What changed, and why it matters
This commit only adds new automated fuzz tests for Lightning Network message and onion-payload codecs. It does not change any production code, so it cannot introduce a runtime vulnerability by itself. The tests are designed to catch crashes or inconsistent encoding/decoding in existing codec code by feeding them random bytes.
No security action required. Treat as a normal test-infrastructure improvement. If the fuzz tests later find crashes, those should be triaged as separate findings.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds four new Scala fuzz-test files under eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/ and expands an existing one. They use Jazzer to perform round-trip property checks on LightningMessageCodecs, PaymentOnionCodecs, MessageOnionCodecs, FailureMessageCodecs, and RouteBlindingEncryptedDataCodecs. No production source files are modified.
Changed components
eclair-fuzz test suitefr.acinq.eclair.wire.protocol codec fuzz testsInspect captured patch +372 / −5
diff --git a/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/FailureMessageCodecsFuzzTest.scala b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/FailureMessageCodecsFuzzTest.scala
new file mode 100644
index 0000000..c26f3a4
--- /dev/null
+++ b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/FailureMessageCodecsFuzzTest.scala
@@ -0,0 +1,84 @@
+package fr.acinq.eclair.wire.protocol
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider
+import com.code_intelligence.jazzer.junit.FuzzTest
+import fr.acinq.bitcoin.scalacompat.Protocol
+import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.failureMessageCodec
+import scodec.bits.ByteVector
+
+import java.nio.ByteOrder
+
+/**
+ * Fuzz tests for onion failure message codecs that have structured fields.
+ */
+class FailureMessageCodecsFuzzTest {
+
+ private def failureRoundTrip(data: FuzzedDataProvider, code: Int): Unit = {
+ val wire = Protocol.writeUInt16(code, ByteOrder.BIG_ENDIAN) ++ ByteVector(data.consumeRemainingAsBytes())
+
+ val decoded = failureMessageCodec.decode(wire.bits)
+ if (decoded.isFailure) return
+
+ val encoded1 = failureMessageCodec.encode(decoded.require.value).require
+ val encoded2 = failureMessageCodec.encode(failureMessageCodec.decode(encoded1).require.value).require
+ assert(encoded1 == encoded2)
+ }
+
+ import FailureMessageCodecs.{BADONION, PERM, UPDATE}
+
+ @FuzzTest(maxDuration = "")
+ def fuzzInvalidOnionVersion(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, BADONION | PERM | 4)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzInvalidOnionHmac(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, BADONION | PERM | 5)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzInvalidOnionKey(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, BADONION | PERM | 6)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTemporaryChannelFailure(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, UPDATE | 7)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzAmountBelowMinimum(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, UPDATE | 11)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzFeeInsufficient(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, UPDATE | 12)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzIncorrectCltvExpiry(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, UPDATE | 13)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzExpiryTooSoon(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, UPDATE | 14)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzIncorrectOrUnknownPaymentDetails(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, PERM | 15)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzFinalIncorrectCltvExpiry(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, 18)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzFinalIncorrectHtlcAmount(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, 19)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzChannelDisabled(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, UPDATE | 20)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzInvalidOnionPayload(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, PERM | 22)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzInvalidOnionBlinding(data: FuzzedDataProvider): Unit =
+ failureRoundTrip(data, BADONION | PERM | 24)
+}
diff --git a/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsFuzzTest.scala b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsFuzzTest.scala
index da79672..4d6f334 100644
--- a/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsFuzzTest.scala
+++ b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsFuzzTest.scala
@@ -10,14 +10,13 @@ import java.nio.ByteOrder
/**
* Fuzz tests for Lightning message codecs.
+ *
+ * Each test prepends the two-byte big-endian message type to the fuzz data,
+ * then verifies that the codec does not throw on arbitrary input and that the
+ * canonical encoding is stable.
*/
class LightningMessageCodecsFuzzTest {
- /**
- * Prepend a two-byte big-endian message type to the fuzz data, then verify
- * that the codec does not throw on arbitrary input and that the canonical
- * encoding is stable.
- */
private def codecRoundTrip(data: FuzzedDataProvider, msgType: Int): Unit = {
val wire = Protocol.writeUInt16(msgType, ByteOrder.BIG_ENDIAN) ++ ByteVector(data.consumeRemainingAsBytes())
@@ -29,6 +28,46 @@ class LightningMessageCodecsFuzzTest {
assert(encoded1 == encoded2)
}
+ // Setup messages
+
+ @FuzzTest(maxDuration = "")
+ def fuzzWarning(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 1)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzStfu(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 2)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzPeerStorageStore(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 7)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzPeerStorageRetrieval(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 9)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzInit(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 16)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzError(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 17)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzPing(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 18)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzPong(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 19)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzRecommendedFeerates(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 39409)
+
+ // Channel messages (single-funded)
+
@FuzzTest(maxDuration = "")
def fuzzOpenChannel(data: FuzzedDataProvider): Unit =
codecRoundTrip(data, 32)
@@ -48,4 +87,192 @@ class LightningMessageCodecsFuzzTest {
@FuzzTest(maxDuration = "")
def fuzzChannelReady(data: FuzzedDataProvider): Unit =
codecRoundTrip(data, 36)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzShutdown(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 38)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzClosingSigned(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 39)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzClosingComplete(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 40)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzClosingSig(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41)
+
+ // Interactive transaction messages (dual-funded)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzOpenDualFundedChannel(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 64)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzAcceptDualFundedChannel(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 65)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxAddInput(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 66)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxAddOutput(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 67)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxRemoveInput(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 68)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxRemoveOutput(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 69)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxComplete(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 70)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxSignatures(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 71)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxInitRbf(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 72)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxAckRbf(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 73)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzTxAbort(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 74)
+
+ // HTLC messages
+
+ @FuzzTest(maxDuration = "")
+ def fuzzStartBatch(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 127)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzUpdateAddHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 128)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzUpdateFulfillHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 130)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzUpdateFailHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 131)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzCommitSigBatch(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 53011)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzCommitSig(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 132)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzRevokeAndAck(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 133)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzUpdateFee(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 134)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzUpdateFailMalformedHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 135)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzChannelReestablish(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 136)
+
+ // Routing / gossip messages
+
+ @FuzzTest(maxDuration = "")
+ def fuzzChannelAnnouncement(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 256)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzNodeAnnouncement(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 257)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzChannelUpdate(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 258)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzAnnouncementSignatures(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 259)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzQueryShortChannelIds(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 261)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzReplyShortChannelIdsEnd(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 262)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzQueryChannelRange(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 263)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzReplyChannelRange(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 264)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzGossipTimestampFilter(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 265)
+
+ // Onion messages
+
+ @FuzzTest(maxDuration = "")
+ def fuzzOnionMessage(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 513)
+
+ // On-the-fly funding messages
+
+ @FuzzTest(maxDuration = "")
+ def fuzzWillAddHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41041)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzWillFailHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41042)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzWillFailMalformedHtlc(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41043)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzCancelOnTheFlyFunding(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41044)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzAddFeeCredit(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41045)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzCurrentFeeCredit(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 41046)
+
+ // Splice messages
+
+ @FuzzTest(maxDuration = "")
+ def fuzzSpliceInit(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 37000)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzSpliceAck(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 37002)
+
+ @FuzzTest(maxDuration = "")
+ def fuzzSpliceLocked(data: FuzzedDataProvider): Unit =
+ codecRoundTrip(data, 37004)
}
diff --git a/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/OnionPayloadCodecsFuzzTest.scala b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/OnionPayloadCodecsFuzzTest.scala
new file mode 100644
index 0000000..0cb8ca2
--- /dev/null
+++ b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/OnionPayloadCodecsFuzzTest.scala
@@ -0,0 +1,33 @@
+package fr.acinq.eclair.wire.protocol
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider
+import com.code_intelligence.jazzer.junit.FuzzTest
+import scodec.bits.ByteVector
+
+/**
+ * Fuzz tests for onion payload codecs.
+ */
+class OnionPayloadCodecsFuzzTest {
+
+ @FuzzTest(maxDuration = "")
+ def fuzzPaymentOnionPerHopPayload(data: FuzzedDataProvider): Unit = {
+ val wire = ByteVector(data.consumeRemainingAsBytes())
+ val decoded = PaymentOnionCodecs.perHopPayloadCodec.decode(wire.bits)
+ if (decoded.isFailure) return
+
+ val encoded1 = PaymentOnionCodecs.perHopPayloadCodec.encode(decoded.require.value).require
+ val encoded2 = PaymentOnionCodecs.perHopPayloadCodec.encode(PaymentOnionCodecs.perHopPayloadCodec.decode(encoded1).require.value).require
+ assert(encoded1 == encoded2)
+ }
+
+ @FuzzTest(maxDuration = "")
+ def fuzzMessageOnionPerHopPayload(data: FuzzedDataProvider): Unit = {
+ val wire = ByteVector(data.consumeRemainingAsBytes())
+ val decoded = MessageOnionCodecs.perHopPayloadCodec.decode(wire.bits)
+ if (decoded.isFailure) return
+
+ val encoded1 = MessageOnionCodecs.perHopPayloadCodec.encode(decoded.require.value).require
+ val encoded2 = MessageOnionCodecs.perHopPayloadCodec.encode(MessageOnionCodecs.perHopPayloadCodec.decode(encoded1).require.value).require
+ assert(encoded1 == encoded2)
+ }
+}
diff --git a/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingCodecsFuzzTest.scala b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingCodecsFuzzTest.scala
new file mode 100644
index 0000000..fe26bf2
--- /dev/null
+++ b/eclair-fuzz/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingCodecsFuzzTest.scala
@@ -0,0 +1,23 @@
+package fr.acinq.eclair.wire.protocol
+
+import com.code_intelligence.jazzer.api.FuzzedDataProvider
+import com.code_intelligence.jazzer.junit.FuzzTest
+import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec
+import scodec.bits.ByteVector
+
+/**
+ * Fuzz tests for route blinding codecs.
+ */
+class RouteBlindingCodecsFuzzTest {
+
+ @FuzzTest(maxDuration = "")
+ def fuzzBlindedRouteData(data: FuzzedDataProvider): Unit = {
+ val wire = ByteVector(data.consumeRemainingAsBytes())
+ val decoded = blindedRouteDataCodec.decode(wire.bits)
+ if (decoded.isFailure) return
+
+ val encoded1 = blindedRouteDataCodec.encode(decoded.require.value).require
+ val encoded2 = blindedRouteDataCodec.encode(blindedRouteDataCodec.decode(encoded1).require.value).require
+ assert(encoded1 == encoded2)
+ }
+}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.