From 8ff7f4b03e408cfae083220d26149c842854bb6a Mon Sep 17 00:00:00 2001 From: Pierre-Marie Padiou Date: Mon, 10 Feb 2020 13:37:23 +0100 Subject: [PATCH] Payment request: ignore fields with invalid length (#1308) * Ignore fields with invalid length As per the spec: > A reader: > * MUST skip over unknown fields, OR an f field with unknown version, OR p, h, s or n fields that do NOT have data_lengths of 52, 52, 52 or 53, respectively. * Add more Bolt 11 tests See https://github.com/lightningnetwork/lightning-rfc/pull/699 and https://github.com/lightningnetwork/lightning-rfc/pull/736 Co-authored-by: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> --- .../acinq/eclair/payment/PaymentRequest.scala | 28 ++++-- .../eclair/payment/PaymentRequestSpec.scala | 99 ++++++++++++++++--- 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala index 929c859f70..0e700c99bf 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentRequest.scala @@ -21,9 +21,9 @@ import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, Block, ByteVector32, ByteV import fr.acinq.eclair.Features.{PaymentSecret => PaymentSecretF, _} import fr.acinq.eclair.payment.PaymentRequest._ import fr.acinq.eclair.{CltvExpiryDelta, FeatureSupport, LongToBtcAmount, MilliSatoshi, ShortChannelId, randomBytes32} -import scodec.Codec import scodec.bits.{BitVector, ByteOrdering, ByteVector} import scodec.codecs.{list, ubyte} +import scodec.{Codec, Err} import scala.compat.Platform import scala.concurrent.duration._ @@ -160,9 +160,11 @@ object PaymentRequest { sealed trait UnknownTaggedField extends TaggedField + sealed trait InvalidTaggedField extends TaggedField + // @formatter:off case class UnknownTag0(data: BitVector) extends UnknownTaggedField - case class UnknownTag1(data: BitVector) extends UnknownTaggedField + case class InvalidTag1(data: BitVector) extends InvalidTaggedField case class UnknownTag2(data: BitVector) extends UnknownTaggedField case class UnknownTag4(data: BitVector) extends UnknownTaggedField case class UnknownTag7(data: BitVector) extends UnknownTaggedField @@ -172,12 +174,14 @@ object PaymentRequest { case class UnknownTag12(data: BitVector) extends UnknownTaggedField case class UnknownTag14(data: BitVector) extends UnknownTaggedField case class UnknownTag15(data: BitVector) extends UnknownTaggedField + case class InvalidTag16(data: BitVector) extends InvalidTaggedField case class UnknownTag17(data: BitVector) extends UnknownTaggedField case class UnknownTag18(data: BitVector) extends UnknownTaggedField case class UnknownTag19(data: BitVector) extends UnknownTaggedField case class UnknownTag20(data: BitVector) extends UnknownTaggedField case class UnknownTag21(data: BitVector) extends UnknownTaggedField case class UnknownTag22(data: BitVector) extends UnknownTaggedField + case class InvalidTag23(data: BitVector) extends InvalidTaggedField case class UnknownTag25(data: BitVector) extends UnknownTaggedField case class UnknownTag26(data: BitVector) extends UnknownTaggedField case class UnknownTag27(data: BitVector) extends UnknownTaggedField @@ -377,11 +381,17 @@ object PaymentRequest { val dataLengthCodec: Codec[Long] = uint(10).xmap(_ * 5, s => (s / 5 + (if (s % 5 == 0) 0 else 1)).toInt) - def dataCodec[A](valueCodec: Codec[A]): Codec[A] = paddedVarAlignedBits(dataLengthCodec, valueCodec, multipleForPadding = 5) + def dataCodec[A](valueCodec: Codec[A], expectedLength: Option[Long] = None): Codec[A] = paddedVarAlignedBits( + dataLengthCodec.narrow(l => if (expectedLength.getOrElse(l) == l) Attempt.successful(l) else Attempt.failure(Err(s"invalid length $l")), l => l), + valueCodec, + multipleForPadding = 5) val taggedFieldCodec: Codec[TaggedField] = discriminated[TaggedField].by(ubyte(5)) .typecase(0, dataCodec(bits).as[UnknownTag0]) - .typecase(1, dataCodec(bytes32).as[PaymentHash]) + .\(1) { + case a: PaymentHash => a: TaggedField + case a: InvalidTag1 => a: TaggedField + }(choice(dataCodec(bytes32, expectedLength = Some(52 * 5)).as[PaymentHash].upcast[TaggedField], dataCodec(bits).as[InvalidTag1].upcast[TaggedField])) .typecase(2, dataCodec(bits).as[UnknownTag2]) .typecase(3, dataCodec(listOfN(extraHopsLengthCodec, extraHopCodec)).as[RoutingInfo]) .typecase(4, dataCodec(bits).as[UnknownTag4]) @@ -396,14 +406,20 @@ object PaymentRequest { .typecase(13, dataCodec(alignedBytesCodec(utf8)).as[Description]) .typecase(14, dataCodec(bits).as[UnknownTag14]) .typecase(15, dataCodec(bits).as[UnknownTag15]) - .typecase(16, dataCodec(bytes32).as[PaymentSecret]) + .\(16) { + case a: PaymentSecret => a: TaggedField + case a: InvalidTag16 => a: TaggedField + }(choice(dataCodec(bytes32, expectedLength = Some(52 * 5)).as[PaymentSecret].upcast[TaggedField], dataCodec(bits).as[InvalidTag16].upcast[TaggedField])) .typecase(17, dataCodec(bits).as[UnknownTag17]) .typecase(18, dataCodec(bits).as[UnknownTag18]) .typecase(19, dataCodec(bits).as[UnknownTag19]) .typecase(20, dataCodec(bits).as[UnknownTag20]) .typecase(21, dataCodec(bits).as[UnknownTag21]) .typecase(22, dataCodec(bits).as[UnknownTag22]) - .typecase(23, dataCodec(bytes32).as[DescriptionHash]) + .\(23) { + case a: DescriptionHash => a: TaggedField + case a: InvalidTag23 => a: TaggedField + }(choice(dataCodec(bytes32, expectedLength = Some(52 * 5)).as[DescriptionHash].upcast[TaggedField], dataCodec(bits).as[InvalidTag23].upcast[TaggedField])) .typecase(24, dataCodec(bits).as[MinFinalCltvExpiry]) .typecase(25, dataCodec(bits).as[UnknownTag25]) .typecase(26, dataCodec(bits).as[UnknownTag26]) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala index a81009bb36..b7a0c10407 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentRequestSpec.scala @@ -218,7 +218,34 @@ class PaymentRequestSpec extends FunSuite { } test("On mainnet, please send $30 for coffee beans to the same peer, which supports features 9, 15 and 99, using secret 0x1111111111111111111111111111111111111111111111111111111111111111") { - val ref = "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqpqsq67gye39hfg3zd8rgc80k32tvy9xk2xunwm5lzexnvpx6fd77en8qaq424dxgt56cag2dpt359k3ssyhetktkpqh24jqnjyw6uqd08sgptq44qu" + val refs = Seq( + "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqpqsq67gye39hfg3zd8rgc80k32tvy9xk2xunwm5lzexnvpx6fd77en8qaq424dxgt56cag2dpt359k3ssyhetktkpqh24jqnjyw6uqd08sgptq44qu", + // All upper-case + "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqpqsq67gye39hfg3zd8rgc80k32tvy9xk2xunwm5lzexnvpx6fd77en8qaq424dxgt56cag2dpt359k3ssyhetktkpqh24jqnjyw6uqd08sgptq44qu".toUpperCase, + // With ignored fields + "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqpqsq2qrqqqfppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhpnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnpkqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2jxxfsnucm4jf4zwtznpaxphce606fvhvje5x7d4gw7n73994hgs7nteqvenq8a4ml8aqtchv5d9pf7l558889hp4yyrqv6a7zpq9fgpskqhza" + ) + + for (ref <- refs) { + val pr = PaymentRequest.read(ref) + assert(pr.prefix === "lnbc") + assert(pr.amount === Some(2500000000L msat)) + assert(pr.paymentHash.bytes === hex"0001020304050607080900010203040506070809000102030405060708090102") + assert(pr.paymentSecret === Some(ByteVector32(hex"1111111111111111111111111111111111111111111111111111111111111111"))) + assert(pr.timestamp === 1496314658L) + assert(pr.nodeId === PublicKey(hex"03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")) + assert(pr.description === Left("coffee beans")) + assert(pr.features.bitmask === bin"1000000000000000000000000000000000000000000000000000000000000000000000000000000000001000001000000000") + assert(!pr.features.allowMultiPart) + assert(!pr.features.requirePaymentSecret) + assert(!pr.features.allowTrampoline) + assert(pr.features.supported) + assert(PaymentRequest.write(pr.sign(priv)) === ref.toLowerCase) + } + } + + test("On mainnet, please send $30 for coffee beans to the same peer, which supports features 9, 15, 99 and 100, using secret 0x1111111111111111111111111111111111111111111111111111111111111111") { + val ref = "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q4psqqqqqqqqqqqqqqqpqsqq40wa3khl49yue3zsgm26jrepqr2eghqlx86rttutve3ugd05em86nsefzh4pfurpd9ek9w2vp95zxqnfe2u7ckudyahsa52q66tgzcp6t2dyk" val pr = PaymentRequest.read(ref) assert(pr.prefix === "lnbc") assert(pr.amount === Some(2500000000L msat)) @@ -228,33 +255,51 @@ class PaymentRequestSpec extends FunSuite { assert(pr.nodeId === PublicKey(hex"03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")) assert(pr.description === Left("coffee beans")) assert(pr.fallbackAddress().isEmpty) - assert(pr.features.bitmask === bin"1000000000000000000000000000000000000000000000000000000000000000000000000000000000001000001000000000") + assert(pr.features.bitmask === bin"000011000000000000000000000000000000000000000000000000000000000000000000000000000000000001000001000000000") assert(!pr.features.allowMultiPart) assert(!pr.features.requirePaymentSecret) assert(!pr.features.allowTrampoline) - assert(pr.features.supported) + assert(!pr.features.supported) assert(PaymentRequest.write(pr.sign(priv)) === ref) } - test("On mainnet, please send $30 for coffee beans to the same peer, which supports features 9, 15, 99 and 100, using secret 0x1111111111111111111111111111111111111111111111111111111111111111") { - val ref = "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q4psqqqqqqqqqqqqqqqpqsqq40wa3khl49yue3zsgm26jrepqr2eghqlx86rttutve3ugd05em86nsefzh4pfurpd9ek9w2vp95zxqnfe2u7ckudyahsa52q66tgzcp6t2dyk" + test("On mainnet, please send 0.00967878534 BTC for a list of items within one week, amount in pico-BTC") { + val ref = "lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9qn07ytgrxxzad9hc4xt3mawjjt8znfv8xzscs7007v9gh9j569lencxa8xeujzkxs0uamak9aln6ez02uunw6rd2ht2sqe4hz8thcdagpleym0j" val pr = PaymentRequest.read(ref) assert(pr.prefix === "lnbc") - assert(pr.amount === Some(2500000000L msat)) - assert(pr.paymentHash.bytes === hex"0001020304050607080900010203040506070809000102030405060708090102") - assert(pr.paymentSecret === Some(ByteVector32(hex"1111111111111111111111111111111111111111111111111111111111111111"))) - assert(pr.timestamp === 1496314658L) + assert(pr.amount === Some(967878534 msat)) + assert(pr.paymentHash.bytes === hex"462264ede7e14047e9b249da94fefc47f41f7d02ee9b091815a5506bc8abf75f") + assert(pr.timestamp === 1572468703L) assert(pr.nodeId === PublicKey(hex"03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad")) - assert(pr.description === Left("coffee beans")) + assert(pr.description === Left("Blockstream Store: 88.85 USD for Blockstream Ledger Nano S x 1, \"Back In My Day\" Sticker x 2, \"I Got Lightning Working\" Sticker x 2 and 1 more items")) assert(pr.fallbackAddress().isEmpty) - assert(pr.features.bitmask === bin"000011000000000000000000000000000000000000000000000000000000000000000000000000000000000001000001000000000") - assert(!pr.features.allowMultiPart) - assert(!pr.features.requirePaymentSecret) - assert(!pr.features.allowTrampoline) - assert(!pr.features.supported) + assert(pr.expiry === Some(604800L)) + assert(pr.minFinalCltvExpiryDelta === Some(CltvExpiryDelta(10))) + assert(pr.routingInfo === Seq(Seq(ExtraHop(PublicKey(hex"03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7"), ShortChannelId("589390x3312x1"), 1000 msat, 2500, CltvExpiryDelta(40))))) + assert(pr.features.supported) assert(PaymentRequest.write(pr.sign(priv)) === ref) } + test("reject invalid invoices") { + val refs = Seq( + // Bech32 checksum is invalid. + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt", + // Malformed bech32 string (no 1). + "pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny", + // Malformed bech32 string (mixed case). + "LNBC2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny", + // Signature is not recoverable. + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaxtrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspk28uwq", + // String is too short. + "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh", + // Invalid multiplier. + "lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpujr6jxr9gq9pv6g46y7d20jfkegkg4gljz2ea2a3m9lmvvr95tq2s0kvu70u3axgelz3kyvtp2ywwt0y8hkx2869zq5dll9nelr83zzqqpgl2zg" + ) + for (ref <- refs) { + assertThrows[Exception](PaymentRequest.read(ref)) + } + } + test("correctly serialize/deserialize variable-length tagged fields") { val number = 123456 @@ -293,6 +338,30 @@ class PaymentRequestSpec extends FunSuite { val Some(_) = pr1.tags.collectFirst { case u: UnknownTag21 => u } } + test("ignore hash tags with invalid length") { + // Bolt11: A reader: MUST skip over p, h, s or n fields that do NOT have data_lengths of 52, 52, 52 or 53, respectively. + def bits(i: Int) = BitVector.fill(i * 5)(high = false) + + val inputs = Map( + "ppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> InvalidTag1(bits(51)), + "pp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> InvalidTag1(bits(53)), + "hpnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> InvalidTag23(bits(51)), + "hp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> InvalidTag23(bits(53)), + "spnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> InvalidTag16(bits(51)), + "sp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> InvalidTag16(bits(53)), + "np5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> UnknownTag19(bits(52)), + "npkqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" -> UnknownTag19(bits(54)) + ) + + for ((input, value) <- inputs) { + val data = string2Bits(input) + val decoded = Codecs.taggedFieldCodec.decode(data).require.value + assert(decoded === value) + val encoded = Codecs.taggedFieldCodec.encode(value).require + assert(encoded === data) + } + } + test("accept uppercase payment request") { val input = "lntb1500n1pwxx94fpp5q3xzmwuvxpkyhz6pvg3fcfxz0259kgh367qazj62af9rs0pw07dsdpa2fjkzep6yp58garswvaz7tmvd9nksarwd9hxw6n0w4kx2tnrdakj7grfwvs8wcqzysxqr23sjzv0d8794te26xhexuc26eswf9sjpv4t8sma2d9y8dmpgf0qseg8259my8tcs6zte7ex0tz4exm5pjezuxrq9u0vjewa02qhedk9x4gppweupu"