What changed, and why it matters
This change tightens validation of Bitcoin fallback addresses embedded in Lightning invoices (Bolt 11). Previously, Eclair would accept invoices containing fallback addresses that were malformed or invalid for the invoice's network. Now it rejects such invoices during decoding. This is a defensive correctness fix that prevents downstream bugs or confusion, but the commit itself does not describe a specific exploitable vulnerability.
Treat as a low-to-moderate hardening fix. Review whether any production paths relied on decoding invoices with invalid fallback addresses, and ensure the new validation does not break legitimate invoices. No emergency response is indicated by the commit alone.
Security signals we found
Input validation added for parsed fallback address tags in Bolt 11 invoices
Invalid fallback addresses now cause invoice decoding to fail instead of being silently accepted
Existing test relying on an invalid fallback address was removed, indicating prior behavior accepted malformed data
Fixes referenced issue #3135, but no exploit details are provided in the commit
Evidence from the diff
The patch adds a require() in Bolt11Invoice construction that re-runs every FallbackAddress through FallbackAddress.toAddress(…) and fails construction if any throws. It also refactors toAddress to use f.data directly. A test using an invoice with an invalid fallback address was removed, and a new negative test case was added to the ‘invalid invoices’ list. The fix addresses issue #3135, whose details are not supplied.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scalaeclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scalaInspect captured patch +13 / −38
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala
index ab669d2..d779b6d 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt11Invoice.scala
@@ -47,6 +47,7 @@ case class Bolt11Invoice(prefix: String, amount_opt: Option[MilliSatoshi], creat
require(tags.collect { case _: Bolt11Invoice.PaymentHash => }.size == 1, "there must be exactly one payment hash tag")
require(tags.collect { case Bolt11Invoice.Description(_) | Bolt11Invoice.DescriptionHash(_) => }.size == 1, "there must be exactly one description tag or one description hash tag")
require(tags.collect { case _: Bolt11Invoice.PaymentSecret => }.size == 1, "there must be exactly one payment secret tag")
+ require(tags.collect { case f: Bolt11Invoice.FallbackAddress => Try(FallbackAddress(Bolt11Invoice.FallbackAddress.toAddress(f, prefix))) }.forall(_.isSuccess), "invalid fallback address")
require(Features.validateFeatureGraph(features).isEmpty, Features.validateFeatureGraph(features).map(_.message))
lazy val paymentHash: ByteVector32 = tags.collectFirst { case p: Bolt11Invoice.PaymentHash => p.hash }.get
@@ -269,16 +270,15 @@ object Bolt11Invoice {
}
def toAddress(f: FallbackAddress, prefix: String): String = {
- import f.data
f.version match {
- case 17 if prefix == "lnbc" => Base58Check.encode(Base58.Prefix.PubkeyAddress, data.toArray)
- case 18 if prefix == "lnbc" => Base58Check.encode(Base58.Prefix.ScriptAddress, data.toArray)
- case 17 if prefix == "lntb" || prefix == "lnbcrt" || prefix == "lntbs" => Base58Check.encode(Base58.Prefix.PubkeyAddressTestnet, data.toArray)
- case 18 if prefix == "lntb" || prefix == "lnbcrt" || prefix == "lntbs" => Base58Check.encode(Base58.Prefix.ScriptAddressTestnet, data.toArray)
- case version if prefix == "lnbc" => Bech32.encodeWitnessAddress("bc", version, data.toArray)
- case version if prefix == "lntb" => Bech32.encodeWitnessAddress("tb", version, data.toArray)
- case version if prefix == "lnbcrt" => Bech32.encodeWitnessAddress("bcrt", version, data.toArray)
- case version if prefix == "lntbs" => Bech32.encodeWitnessAddress("tb", version, data.toArray)
+ case 17 if prefix == "lnbc" => Base58Check.encode(Base58.Prefix.PubkeyAddress, f.data.toArray)
+ case 18 if prefix == "lnbc" => Base58Check.encode(Base58.Prefix.ScriptAddress, f.data.toArray)
+ case 17 if prefix == "lntb" || prefix == "lnbcrt" || prefix == "lntbs" => Base58Check.encode(Base58.Prefix.PubkeyAddressTestnet, f.data.toArray)
+ case 18 if prefix == "lntb" || prefix == "lnbcrt" || prefix == "lntbs" => Base58Check.encode(Base58.Prefix.ScriptAddressTestnet, f.data.toArray)
+ case version if prefix == "lnbc" => Bech32.encodeWitnessAddress("bc", version, f.data.toArray)
+ case version if prefix == "lntb" => Bech32.encodeWitnessAddress("tb", version, f.data.toArray)
+ case version if prefix == "lnbcrt" => Bech32.encodeWitnessAddress("bcrt", version, f.data.toArray)
+ case version if prefix == "lntbs" => Bech32.encodeWitnessAddress("tb", version, f.data.toArray)
}
}
}
@@ -516,11 +516,11 @@ object Bolt11Invoice {
val lowercaseInput = input.toLowerCase
val separatorIndex = lowercaseInput.lastIndexOf('1')
val hrp = lowercaseInput.take(separatorIndex)
- val prefix: String = prefixes.values.toSeq.sortBy(_.length).findLast(prefix => hrp.startsWith(prefix)).getOrElse(throw new RuntimeException("unknown prefix"))
+ val prefix = prefixes.values.toSeq.sortBy(_.length).findLast(prefix => hrp.startsWith(prefix)).getOrElse(throw new RuntimeException("unknown prefix"))
val data = string2Bits(lowercaseInput.slice(separatorIndex + 1, lowercaseInput.length - 6)) // 6 == checksum size
val bolt11Data = Codecs.bolt11DataCodec.decode(data).require.value
val signature = ByteVector64(bolt11Data.signature.take(64))
- val message: ByteVector = ByteVector.view(hrp.getBytes) ++ data.dropRight(520).toByteVector // we drop the sig bytes
+ val message = ByteVector.view(hrp.getBytes) ++ data.dropRight(520).toByteVector // we drop the sig bytes
val recid = bolt11Data.signature.last
val pub = Crypto.recoverPublicKey(signature, Crypto.sha256(message), recid)
// README: since we use pubkey recovery to compute the node id from the message and signature, we don't check the signature.
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala
index dc185b4..c74440e 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt11InvoiceSpec.scala
@@ -306,33 +306,6 @@ class Bolt11InvoiceSpec extends AnyFunSuite {
assert(invoice.sign(priv).toString == ref)
}
- test("On mainnet, please send $30 for coffee beans to the same peer, which supports features 8, 14 and 99, using secret 0x1111111111111111111111111111111111111111111111111111111111111111") {
- val refs = Seq(
- "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa",
- // All upper-case
- "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa".toUpperCase,
- // With ignored fields
- "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2qrqqqfppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhpnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnpkqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqz599y53s3ujmcfjp5xrdap68qxymkqphwsexhmhr8wdz5usdzkzrse33chw6dlp3jhuhge9ley7j2ayx36kawe7kmgg8sv5ugdyusdcqzn8z9x"
- )
-
- for (ref <- refs) {
- val Success(invoice) = Bolt11Invoice.fromString(ref)
- assert(invoice.prefix == "lnbc")
- assert(invoice.amount_opt.contains(2500000000L msat))
- assert(invoice.paymentHash.bytes == hex"0001020304050607080900010203040506070809000102030405060708090102")
- assert(invoice.paymentSecret.bytes == hex"1111111111111111111111111111111111111111111111111111111111111111")
- assert(invoice.createdAt == TimestampSecond(1496314658L))
- assert(invoice.nodeId == PublicKey(hex"03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"))
- assert(invoice.description == Left("coffee beans"))
- assert(features2bits(invoice.features) == bin"1000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000100000000")
- assert(!invoice.features.hasFeature(BasicMultiPartPayment))
- assert(invoice.features.hasFeature(PaymentSecret, Some(Mandatory)))
- assert(!invoice.features.hasFeature(TrampolinePaymentPrototype))
- assert(TestConstants.Alice.nodeParams.features.invoiceFeatures().areSupported(invoice.features))
- assert(invoice.sign(priv).toString == ref.toLowerCase)
- }
- }
-
test("On mainnet, please send $30 for coffee beans to the same peer, which supports features 8, 14, 99 and 100, using secret 0x1111111111111111111111111111111111111111111111111111111111111111") {
val ref = "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q4psqqqqqqqqqqqqqqqqsgqtqyx5vggfcsll4wu246hz02kp85x4katwsk9639we5n5yngc3yhqkm35jnjw4len8vrnqnf5ejh0mzj9n3vz2px97evektfm2l6wqccp3y7372"
val Success(invoice) = Bolt11Invoice.fromString(ref)
@@ -430,6 +403,8 @@ class Bolt11InvoiceSpec extends AnyFunSuite {
"lnbc1p5q54jjpp5fe0dhqdt4m97psq0fv3wjlk95cclnatvuvq49xtnc8rzrp0dysusdqqcqzzsxqrrs0fppqy6uew5229e67r9xzzm9mjyfwseclstdgsp5rnanj9x5rnanj9xnq28hhgd6c7yxlmh6lta047h6lqqqqqqqqqqqrqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6qqqqqqqqqqqqqqqqqqq9kvnknh7ug5mttnqqqqqqqqq8849gwfhvnp9rqpe0cy97",
// Invalid min_final_expiry_delta_blocks.
"lnbc1p5q54jjpp5fe0dhqdt4m97psq0fv3wjlk95cclnatvuvq49xtnc8rzrp0d5susdqqcqg3vrywwwjsppqy6uew5229e67rzxzzm9mjyfwseclstdgsp5rnanj9xnq28hhgd6c7yxlmh6lta047h6lqqqqqqqqqqqrqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq9kvnknh7ug5mttn2yu5ha6m98cpda2rtwu08849gwfhvnp9rqpqqqqqqqqqg58lts",
+ // Invalid fallback address.
+ "lnbc1qzupp9qsp5pvgsuqqpgczuppczc3pcz3syzy8q2xqqqqqqqqqqqqqqqqqygh9qpp5s7zxqqqqqqqqqqyqymqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhp5qs97qqqqqqqpqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqptfqptfqptfqptfqptfqptfqptfqptfq95xtfqptfqp3w9chzut3w9chj95xw7tfpp35qqqw9chzuqt3w9chzut3qptfqptfqptfqptfqptfqptfqpqw9cqqqqt28y39",
)
for (ref <- refs) {
assert(Bolt11Invoice.fromString(ref).isFailure)
Why this scored 51/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.