What changed, and why it matters
This commit loosens Eclair's validation of BOLT12 invoice onion messages so that invoices may now include a reply path. Previously, Eclair rejected invoices that carried a reply path, which caused compatibility problems with LDK nodes. The change is framed as an interoperability fix, not a security fix, and it tightens some related validation rules at the same time.
Treat as a protocol-compatibility fix rather than a security patch. Reviewers should confirm that allowing ReplyPath on invoices does not enable unexpected message routing or invoice_error injection, and that the new stricter rules for unknown TLVs and missing reply paths do not break other BOLT12 implementations.
Security signals we found
Loosened TLV validation for BOLT12 Invoice messages now permits ReplyPath presence
Added explicit rejection of unknown records (even odd ones) in final payload
Added explicit requirement for ReplyPath when an InvoiceRequest is present
Changed fallback MissingRequiredTlv tag from UInt64(0) to UInt64(68)
No mention of vulnerability, CVE, or security advisory in commit message
Evidence from the diff
In MessageOnion.scala’s FinalPayload.validate, the pattern for accepting an Invoice payload changes from requiring no ReplyPath to allowing an optional ReplyPath. The commit also adds an explicit rejection of invoice_request messages without a reply path, disallows unknown records entirely, and updates the fallback error tag from 0 to 68. A test case that expected invoice+reply_path to be invalid is removed and replaced with one expecting it to be valid.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/MessageOnion.scalaBOLT12 invoice onion-message validationInspect captured patch +14 / −6
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/MessageOnion.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/MessageOnion.scala
index 2aaa52d..fec9b0c 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/MessageOnion.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/MessageOnion.scala
@@ -119,13 +119,18 @@ object MessageOnion {
def validate(records: TlvStream[OnionMessagePayloadTlv], blindedRecords: TlvStream[RouteBlindingEncryptedDataTlv]): Either[InvalidTlvPayload, FinalPayload] = {
BlindedRouteData.validateMessageRecipientData(blindedRecords).map(_ =>
(records.get[InvoiceRequest], records.get[Invoice], records.get[InvoiceError], records.get[ReplyPath]) match {
+ // We disallow unknown records entirely, even odd ones.
case _ if records.unknown.nonEmpty => InvalidResponsePayload(records, blindedRecords, ForbiddenTlv(records.unknown.head.tag))
+ // When receiving an invoice_request, we need a reply path to send our invoice back.
+ case (Some(_), _, _, None) => InvalidResponsePayload(records, blindedRecords, MissingRequiredTlv(UInt64(2)))
case (Some(invoiceRequest), None, None, Some(_)) =>
OfferTypes.InvoiceRequest.validate(invoiceRequest.tlvs) match {
case Left(failure) => InvalidResponsePayload(records, blindedRecords, failure)
case Right(_) => InvoiceRequestPayload(records, blindedRecords)
}
- case (None, Some(invoice), None, None) =>
+ // Invoices may include a reply path for a potential invoice_error (even though it's not strictly necessary
+ // since we've already reached them with our invoice_request and can reuse the same path).
+ case (None, Some(invoice), None, _) =>
Bolt12Invoice.validate(invoice.tlvs) match {
case Left(failure) => InvalidResponsePayload(records, blindedRecords, failure)
case Right(_) => InvoicePayload(records, blindedRecords)
@@ -135,7 +140,9 @@ object MessageOnion {
case Left(failure) => InvalidResponsePayload(records, blindedRecords, failure)
case Right(_) => InvoiceErrorPayload(records, blindedRecords)
}
- case _ => InvalidResponsePayload(records, blindedRecords, MissingRequiredTlv(UInt64(0)))
+ // If the message doesn't contain an invoice, invoice_request or invoice_error, or contains more than one of
+ // those fields, we don't know what the sender meant: we act as if they didn't include an invoice_error.
+ case _ => InvalidResponsePayload(records, blindedRecords, MissingRequiredTlv(UInt64(68)))
}
)
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/MessageOnionCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/MessageOnionCodecsSpec.scala
index ed4f5d7..74867c6 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/MessageOnionCodecsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/MessageOnionCodecsSpec.scala
@@ -5,13 +5,13 @@ import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32}
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.crypto.Sphinx.RouteBlinding
import fr.acinq.eclair.payment.{Bolt12Invoice, PaymentBlindedRoute}
-import fr.acinq.eclair.wire.protocol.MessageOnion.{FinalPayload, IntermediatePayload, InvalidResponsePayload, InvoiceErrorPayload, InvoicePayload, InvoiceRequestPayload}
+import fr.acinq.eclair.wire.protocol.MessageOnion._
import fr.acinq.eclair.wire.protocol.MessageOnionCodecs._
import fr.acinq.eclair.wire.protocol.OfferTypes.PaymentInfo
import fr.acinq.eclair.wire.protocol.OnionMessagePayloadTlv._
import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{ForbiddenTlv, InvalidTlvPayload, MissingRequiredTlv}
import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec
-import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataTlv.{AllowedFeatures, OutgoingNodeId, PathId, PaymentConstraints, PaymentRelay}
+import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataTlv._
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, EncodedNodeId, Features, MilliSatoshiLong, UInt64, randomBytes32, randomKey}
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits.{ByteVector, HexStringSyntax}
@@ -105,8 +105,6 @@ class MessageOnionCodecsSpec extends AnyFunSuiteLike {
TlvStream(Set[OnionMessagePayloadTlv](EncryptedData(hex"")), Set(GenericTlv(UInt64(1), hex""))),
// Invoice and unknown TLV.
TlvStream(Set[OnionMessagePayloadTlv](EncryptedData(hex""), Invoice(invoice.records)), Set(GenericTlv(UInt64(1), hex""))),
- // Invoice and ReplyPath.
- TlvStream(EncryptedData(hex""), Invoice(invoice.records), ReplyPath(route.route)),
// Invoice and InvoiceError.
TlvStream(EncryptedData(hex""), Invoice(invoice.records), InvoiceError(TlvStream(OfferTypes.Error("")))),
// InvoiceRequest without ReplyPath.
@@ -124,6 +122,9 @@ class MessageOnionCodecsSpec extends AnyFunSuiteLike {
val Right(invoicePayload) = FinalPayload.validate(TlvStream(EncryptedData(hex""), Invoice(invoice.records)), TlvStream.empty)
assert(invoicePayload.isInstanceOf[InvoicePayload])
+ val Right(invoiceWithReplyPathPayload) = FinalPayload.validate(TlvStream(EncryptedData(hex""), Invoice(invoice.records), ReplyPath(route.route)), TlvStream.empty)
+ assert(invoiceWithReplyPathPayload.isInstanceOf[InvoicePayload])
+
val Right(invoiceErrorPayload) = FinalPayload.validate(TlvStream(EncryptedData(hex""), InvoiceError(TlvStream(OfferTypes.Error("")))), TlvStream.empty)
assert(invoiceErrorPayload.isInstanceOf[InvoiceErrorPayload])
}
Why this scored 30/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.