From ba13ced061d2978158a63184dea5ff0a5e5bdc98 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Wed, 22 Jan 2025 12:49:11 +0100 Subject: [PATCH 1/3] Validate offers format Enforce formatting defined in https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#encoding --- .../eclair/wire/protocol/OfferTypes.scala | 11 +++- .../test/resources/format-string-test.json | 62 +++++++++++++++++++ .../eclair/wire/protocol/OfferTypesSpec.scala | 18 ++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 eclair-core/src/test/resources/format-string-test.json diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index eea4b8aa68..859a2b6dc0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -314,8 +314,17 @@ object OfferTypes { Right(Offer(records)) } + def validateFormat(s: String): String = { + val lowercase = s.toLowerCase + require(s == lowercase || s == s.toUpperCase) + require(lowercase.head == 'l') + require(Bech32.alphabet.contains(lowercase.last)) + require(!lowercase.matches(".*\\+\\s*\\+.*")) + lowercase.replaceAll("\\+\\s*", "") + } + def decode(s: String): Try[Offer] = Try { - val triple = Bech32.decodeBytes(s.toLowerCase, true) + val triple = Bech32.decodeBytes(validateFormat(s), true) val prefix = triple.getFirst val encoded = triple.getSecond val encoding = triple.getThird diff --git a/eclair-core/src/test/resources/format-string-test.json b/eclair-core/src/test/resources/format-string-test.json new file mode 100644 index 0000000000..869da95778 --- /dev/null +++ b/eclair-core/src/test/resources/format-string-test.json @@ -0,0 +1,62 @@ +[ + { + "comment": "A complete string is valid", + "valid": true, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Uppercase is valid", + "valid": true, + "string": "LNO1PQPS7SJQPGTYZM3QV4UXZMTSD3JJQER9WD3HY6TSW35K7MSJZFPY7NZ5YQCNYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD5XVXG" + }, + { + "comment": "+ can join anywhere", + "valid": true, + "string": "l+no1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Multiple + can join", + "valid": true, + "string": "lno1pqps7sjqpgt+yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+5k7msjzfpy7nz5yqcn+ygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+5xvxg" + }, + { + "comment": "+ can be followed by whitespace", + "valid": true, + "string": "lno1pqps7sjqpgt+ yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+ 5k7msjzfpy7nz5yqcn+\nygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+\r\n 5xvxg" + }, + { + "comment": "+ can be followed by whitespace, UPPERCASE", + "valid": true, + "string": "LNO1PQPS7SJQPGT+ YZM3QV4UXZMTSD3JJQER9WD3HY6TSW3+ 5K7MSJZFPY7NZ5YQCN+\nYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD+\r\n 5XVXG" + }, + { + "comment": "Mixed case is invalid", + "valid": false, + "string": "LnO1PqPs7sJqPgTyZm3qV4UxZmTsD3JjQeR9Wd3hY6TsW35k7mSjZfPy7nZ5YqCnYgRfDeJ82uM5Wf5k2uCkYyPwA3EyT44h6tXtXqUqH7Lz5dJgE4AfGfJn7k4rGrKuAg0jSd5xVxG" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+ " + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+ lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "ln++o1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + } +] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala index 9228e1e917..9c056fc67c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala @@ -319,4 +319,22 @@ class OfferTypesSpec extends AnyFunSuite { } } } + + case class FormatTestVector(comment: String, valid: Boolean, string: String) + + test("string format spec test vectors") { + implicit val formats: DefaultFormats.type = DefaultFormats + + val src = Source.fromFile(new File(getClass.getResource(s"/format-string-test.json").getFile)) + val testVectors = JsonMethods.parse(src.mkString).extract[Seq[FormatTestVector]] + src.close() + val canonical = testVectors.head.string + for (vector <- testVectors) { + if (vector.valid) { + assert(Offer.validateFormat(vector.string) == canonical, vector.comment) + } else { + assertThrows[IllegalArgumentException](Offer.validateFormat(vector.string)) + } + } + } } From 98cc265950f2cfb30dd2598744a73b9198b44fd5 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Wed, 22 Jan 2025 14:21:58 +0100 Subject: [PATCH 2/3] Add comment --- .../main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index 859a2b6dc0..5ad99acf88 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -314,6 +314,10 @@ object OfferTypes { Right(Offer(records)) } + /** + * An offer string can be split with '+' to fit in places with a low character limit. This validates that the string adheres to the spec format to guard against copy-pasting errors. + * @return a lowercase string with '+' and whitespaces removed + */ def validateFormat(s: String): String = { val lowercase = s.toLowerCase require(s == lowercase || s == s.toUpperCase) From d4db6819184f3b5b80b7683a3eb01cf8186db68d Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Thu, 23 Jan 2025 12:17:00 +0100 Subject: [PATCH 3/3] decode --- .../scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala | 2 +- .../fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index 5ad99acf88..0428cadab2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -318,7 +318,7 @@ object OfferTypes { * An offer string can be split with '+' to fit in places with a low character limit. This validates that the string adheres to the spec format to guard against copy-pasting errors. * @return a lowercase string with '+' and whitespaces removed */ - def validateFormat(s: String): String = { + private def validateFormat(s: String): String = { val lowercase = s.toLowerCase require(s == lowercase || s == s.toUpperCase) require(lowercase.head == 'l') diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala index 9c056fc67c..988a22c391 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala @@ -328,13 +328,8 @@ class OfferTypesSpec extends AnyFunSuite { val src = Source.fromFile(new File(getClass.getResource(s"/format-string-test.json").getFile)) val testVectors = JsonMethods.parse(src.mkString).extract[Seq[FormatTestVector]] src.close() - val canonical = testVectors.head.string for (vector <- testVectors) { - if (vector.valid) { - assert(Offer.validateFormat(vector.string) == canonical, vector.comment) - } else { - assertThrows[IllegalArgumentException](Offer.validateFormat(vector.string)) - } + assert(Offer.decode(vector.string).isSuccess == vector.valid, vector.comment) } } }