diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt index dc4366f9f..31a4c39bf 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt @@ -2,6 +2,7 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.wire.OfferTypes import io.ktor.utils.io.core.* @@ -29,6 +30,26 @@ data class ContactAddress(val name: String, val domain: String) { } } +/** + * When we receive an invoice_request containing a contact address, we don't immediately fetch the offer from + * the BIP 353 address, because this could otherwise be used as a DoS vector since we haven't received a payment yet. + * + * After receiving the payment, we resolve the BIP 353 address to store the contact. + * In the invoice_request, they committed to the signing key used for their offer. + * We verify that the offer uses this signing key, otherwise the BIP 353 address most likely doesn't belong to them. + */ +data class UnverifiedContactAddress(val address: ContactAddress, val expectedOfferSigningKey: PublicKey) { + /** + * Verify that the offer obtained by resolving the BIP 353 address matches the invoice_request commitment. + * If this returns false, it means that either: + * - the contact address doesn't belong to the node + * - or they changed the signing key of the offer associated with their BIP 353 address + * Since the second case should be very infrequent, it's more likely that the remote node is malicious + * and we shouldn't store them in our contacts list. + */ + fun verify(offer: OfferTypes.Offer): Boolean = expectedOfferSigningKey == offer.issuerId || (offer.paths?.map { it.nodeId }?.toSet() ?: setOf()).contains(expectedOfferSigningKey) +} + /** * Contact secrets are used to mutually authenticate payments. * diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index 7bf2ce46e..2794f4562 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt @@ -118,7 +118,7 @@ sealed class OfferPaymentMetadata { val quantity: Long, val contactSecret: ByteVector32?, val payerOffer: OfferTypes.Offer?, - val payerAddress: ContactAddress?, + val payerAddress: UnverifiedContactAddress?, override val createdAtMillis: Long ) : OfferPaymentMetadata() { override val version: Byte get() = 2 @@ -131,6 +131,16 @@ sealed class OfferPaymentMetadata { } } + private fun writeOptionalContactAddress(payerAddress: UnverifiedContactAddress?, out: Output) = when (payerAddress) { + null -> LightningCodecs.writeU16(0, out) + else -> { + val address = payerAddress.address.toString().encodeToByteArray() + LightningCodecs.writeU16(address.size + 33, out) + LightningCodecs.writeBytes(address, out) + LightningCodecs.writeBytes(payerAddress.expectedOfferSigningKey.value, out) + } + } + fun write(out: Output) { LightningCodecs.writeBytes(offerId, out) LightningCodecs.writeU64(amount.toLong(), out) @@ -140,7 +150,7 @@ sealed class OfferPaymentMetadata { LightningCodecs.writeU64(quantity, out) writeOptionalBytes(contactSecret?.toByteArray(), out) writeOptionalBytes(payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records) }, out) - writeOptionalBytes(payerAddress?.toString()?.encodeToByteArray(), out) + writeOptionalContactAddress(payerAddress, out) LightningCodecs.writeU64(createdAtMillis, out) } @@ -150,6 +160,14 @@ sealed class OfferPaymentMetadata { else -> LightningCodecs.bytes(input, size) } + private fun readOptionalContactAddress(input: Input): UnverifiedContactAddress? = when (val size = LightningCodecs.u16(input)) { + 0 -> null + else -> ContactAddress.fromString(LightningCodecs.bytes(input, size - 33).decodeToString())?.let { address -> + val offerKey = PublicKey(LightningCodecs.bytes(input, 33)) + UnverifiedContactAddress(address, offerKey) + } + } + fun read(input: Input): V2 { val offerId = LightningCodecs.bytes(input, 32).byteVector32() val amount = LightningCodecs.u64(input).msat @@ -159,7 +177,7 @@ sealed class OfferPaymentMetadata { val quantity = LightningCodecs.u64(input) val contactSecret = readOptionalBytes(input)?.byteVector32() val payerOffer = readOptionalBytes(input)?.let { OfferTypes.Offer.tlvSerializer.read(it) }?.let { OfferTypes.Offer(it) } - val payerAddress = readOptionalBytes(input)?.decodeToString()?.let { ContactAddress.fromString(it) } + val payerAddress = readOptionalContactAddress(input) val createdAtMillis = LightningCodecs.u64(input) return V2(offerId, amount, preimage, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress, createdAtMillis) } diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt index 81ca33f52..757bf32c8 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt @@ -14,6 +14,7 @@ import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.message.OnionMessages import fr.acinq.lightning.payment.ContactAddress +import fr.acinq.lightning.payment.UnverifiedContactAddress /** * Lightning Bolt 12 offers @@ -460,6 +461,28 @@ object OfferTypes { } } + /** + * When [[InvoiceRequestPayerAddress]] is included, the invoice request must be signed with the signing key of the offer matching the BIP 353 address. + * This proves that the payer really owns this BIP 353 address. + * See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + */ + data class InvoiceRequestPayerAddressSignature(val offerSigningKey: PublicKey, val signature: ByteVector64) : InvoiceRequestTlv() { + override val tag: Long get() = InvoiceRequestPayerAddressSignature.tag + override fun write(out: Output) { + LightningCodecs.writeBytes(offerSigningKey.value, out) + LightningCodecs.writeBytes(signature, out) + } + + companion object : TlvValueReader { + const val tag: Long = 2_000_001_735L + override fun read(input: Input): InvoiceRequestPayerAddressSignature { + val offerSigningKey = PublicKey(LightningCodecs.bytes(input, 33)) + val signature = LightningCodecs.bytes(input, 64).byteVector64() + return InvoiceRequestPayerAddressSignature(offerSigningKey, signature) + } + } + } + /** * Payment paths to send the payment to. */ @@ -919,7 +942,7 @@ object OfferTypes { val payerNote: String? = records.get()?.note val contactSecret: ByteVector32? = records.get()?.contactSecret val payerOffer: Offer? = records.get()?.offer - val payerAddress: ContactAddress? = records.get()?.address + val payerAddress: UnverifiedContactAddress? = records.get()?.let { pa -> records.get()?.let { ps -> UnverifiedContactAddress(pa.address, ps.offerSigningKey) } } private val signature: ByteVector64 = records.get()!!.signature fun isValid(): Boolean = @@ -928,17 +951,23 @@ object OfferTypes { offer.chains.contains(chain) && ((offer.quantityMax == null && quantity_opt == null) || (offer.quantityMax != null && quantity_opt != null && quantity <= offer.quantityMax)) && Features.areCompatible(offer.features, features) && + checkPayerAddressSignature() && checkSignature() fun requestedAmount(): MilliSatoshi? = amount ?: offer.amount?.let { it * quantity } - fun checkSignature(): Boolean = - verifySchnorr( - signatureTag, - rootHash(removeSignature(records)), - signature, - payerId - ) + private fun checkPayerAddressSignature(): Boolean = when (val ps = records.get()) { + null -> true + else -> { + // The payer address signature covers the invoice request without its top-level signature. + // Note that the standard invoice request signature includes the InvoiceRequestPayerAddressSignature field. + val signedTlvs = TlvStream(records.records.filter { it !is Signature && it !is InvoiceRequestPayerAddressSignature }.toSet(), records.unknown) + val signatureTag = ByteVector(("lightning" + "invoice_request" + "invreq_payer_bip_353_signature").encodeToByteArray()) + verifySchnorr(signatureTag, rootHash(signedTlvs), ps.signature, ps.offerSigningKey) + } + } + + fun checkSignature(): Boolean = verifySchnorr(signatureTag, rootHash(removeSignature(records)), signature, payerId) fun encode(): String { val data = tlvSerializer.write(records) @@ -951,8 +980,7 @@ object OfferTypes { companion object { val hrp = "lnr" - val signatureTag: ByteVector = - ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray()) + val signatureTag: ByteVector = ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray()) /** * Create a request to fetch an invoice for a given offer. @@ -999,9 +1027,10 @@ object OfferTypes { is Left -> return Left(offer.value) is Right -> {} } - if (records.get() == null) return Left(MissingRequiredTlv(0L)) - if (records.get() == null) return Left(MissingRequiredTlv(88)) - if (records.get() == null) return Left(MissingRequiredTlv(240)) + if (records.get() == null) return Left(MissingRequiredTlv(InvoiceRequestMetadata.tag)) + if (records.get() == null && records.get() == null) return Left(MissingRequiredTlv(InvoiceRequestAmount.tag)) + if (records.get() == null) return Left(MissingRequiredTlv(InvoiceRequestPayerId.tag)) + if (records.get() == null) return Left(MissingRequiredTlv(Signature.tag)) if (records.unknown.any { !isInvoiceRequestTlv(it) }) return Left(ForbiddenTlv(records.unknown.find { !isInvoiceRequestTlv(it) }!!.tag)) return Right(InvoiceRequest(records)) } @@ -1031,6 +1060,7 @@ object OfferTypes { InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader, InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader, InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader, + InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader, Signature.tag to Signature as TlvValueReader, ) ) @@ -1073,6 +1103,7 @@ object OfferTypes { InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader, InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader, InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader, + InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader, // Invoice part InvoicePaths.tag to InvoicePaths as TlvValueReader, InvoiceBlindedPay.tag to InvoiceBlindedPay as TlvValueReader, diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt index da4f8f9aa..e07427836 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt @@ -8,6 +8,8 @@ import fr.acinq.lightning.wire.OfferTypes import fr.acinq.lightning.wire.TlvStream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse class ContactsTestsCommon : LightningTestSuite() { @@ -35,6 +37,9 @@ class ContactsTestsCommon : LightningTestSuite() { assertEquals("810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8", contactSecretAlice.primarySecret.toHex()) val contactSecretBob = Contacts.computeContactSecret(bobOfferAndKey, aliceOfferAndKey.offer) assertEquals(contactSecretAlice, contactSecretBob) + val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("bob@acinq.co")!!, bobOfferAndKey.privateKey.publicKey()) + assertTrue(payerAddress.verify(bobOfferAndKey.offer)) + assertFalse(payerAddress.verify(aliceOfferAndKey.offer)) } run { // The remote offer contains an issuer_id and a blinded path. @@ -53,7 +58,8 @@ class ContactsTestsCommon : LightningTestSuite() { assertEquals("4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c", contactSecretAlice.primarySecret.toHex()) val contactSecretBob = Contacts.computeContactSecret(OfferTypes.OfferAndKey(bobOffer, issuerKey), aliceOfferAndKey.offer) assertEquals(contactSecretAlice, contactSecretBob) - + val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("bob@acinq.co")!!, issuerKey.publicKey()) + assertTrue(payerAddress.verify(bobOffer)) } } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt index b702c99fb..47cd1305c 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt @@ -77,7 +77,7 @@ class OfferPaymentMetadataTestsCommon { } @Test - fun `encode - decode v2 metadata with contact information`() { + fun `encode - decode v2 metadata with contact offer`() { val nodeKey = randomKey() val preimage = randomBytes32() val paymentHash = Crypto.sha256(preimage).byteVector32() @@ -111,6 +111,29 @@ class OfferPaymentMetadataTestsCommon { assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash)) } + @Test + fun `encode - decode v2 metadata with contact address`() { + val nodeKey = randomKey() + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).byteVector32() + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 200_000_000.msat, + preimage = preimage, + payerKey = randomKey().publicKey(), + payerNote = "hello there", + quantity = 1, + contactSecret = randomBytes32(), + payerOffer = null, + payerAddress = UnverifiedContactAddress(ContactAddress.fromString("alice@acinq.co")!!, randomKey().publicKey()), + createdAtMillis = 0 + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(236, pathId.size()) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash)) + } + @Test fun `decode invalid path_id`() { val nodeKey = randomKey() diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt index 7c55712e8..e347bd291 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt @@ -12,6 +12,7 @@ import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.payment.ContactAddress +import fr.acinq.lightning.payment.UnverifiedContactAddress import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.testLoggerFactory @@ -24,6 +25,7 @@ import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestChain import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestContactSecret import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestMetadata import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestPayerAddress +import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestPayerAddressSignature import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestPayerId import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestPayerOffer import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestQuantity @@ -221,12 +223,14 @@ class OfferTypesTestsCommon : LightningTestSuite() { val tlvsWithoutSignature = setOf( InvoiceRequestMetadata(ByteVector.fromHex("abcdef")), OfferIssuerId(nodeId), + InvoiceRequestAmount(42.msat), InvoiceRequestPayerId(payerKey.publicKey()), ) val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerKey) val tlvs = tlvsWithoutSignature + Signature(signature) val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) - val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupetqssynwewhp70gwlp4chhm53g90jt9fpnx7rpmrzla3zd0nvxymm8e0p7pq06rwacy8756zgl3hdnsyfepq573astyz94rgn9uhxlyqj4gdyk6q8q0yrv6al909v3435amuvjqvkuq6k8fyld78r8srdyx7wnmwsdu" + assertTrue(invoiceRequest.isValid()) + val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gqj5kppqfxajawru7sa7rt300hfzs2lyk2jrxduxrkx9lmzy6lxcvfhk0j7ruzq3ue5pwwxhwlacj6sp535wxlzsj3elzx7vl40xvsgs7ha04twwewh08sprucqs8smfjgcclvwl9hzps5ap3ugzphvkey70dwkgwu6xhq" assertEquals(encoded, invoiceRequest.encode()) assertEquals(invoiceRequest, InvoiceRequest.decode(encoded).get()) assertNull(invoiceRequest.offer.amount) @@ -244,32 +248,110 @@ class OfferTypesTestsCommon : LightningTestSuite() { } @Test - fun `invoice request with contact info`() { + fun `invoice request with contact offer`() { val payerKey = PrivateKey.fromHex("80803163f4c8422f492ca6a03f5a6ed116a313ebcf9b2c794249a30221e87313") val contactSecret = ByteVector32.fromValidHex("f6b50c250267c2f4b03461f4a8beee114a2e628623a18cda9a54bd7348cf0084") val payerOffer = Offer.decode("lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqzs0wvvqg8lcu47r8kvwpyqevldjvlg7cm0tnzgydz6efr3laa58pqyqht6e54gm2guqsn87mkcneuwh77fxvpmt3akr7u7n90smpudwwhlsqrxglas7t0reqx3e0jwhkr7kwsalpw5txpwdw7lf0rl8vux48ndl6p9u72u3m0kflm8k9nq6jrsu6meftjn0gzxjn3um7hgw8qrs5nrq846dv6yulaccrljdracc73xmujg9k4zc0sqyy2my822usupn2yzpynpcta5dlx").get() - val payerAddress = ContactAddress("phoenix", "acinq.co") val tlvsWithoutSignature = setOf( InvoiceRequestMetadata(ByteVector.fromHex("a37561651a82fbd68b9c243595f45a9bbb6a906808608497842deb0e24588d61")), OfferIssuerId(nodeId), + InvoiceRequestAmount(42.msat), InvoiceRequestPayerId(payerKey.publicKey()), InvoiceRequestContactSecret(contactSecret), InvoiceRequestPayerOffer(payerOffer), - InvoiceRequestPayerAddress(payerAddress), ) val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerKey) val tlvs = tlvsWithoutSignature + Signature(signature) val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) - val encoded = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2cyyp83yerha06wcwlyw670yxv602sg5j0mw6de0px4kx3z2guzst3tfhsgqcvnnlakywy2ft5ra04m72qxnwzxs7mn3r22k5haj5dx69jch98dc23dx0j0xw48xpxtsngmhwzfm32gce2reygm6eycva7x9qqmfvalemntxkpyrmt2rp9qfnu9a9sx3slf297acg55tnzsc36rrx6nf2t6u6geuqgflnhxkdv8uqzypp5jl7hlqnf2ugg7j3slkwwcwht57vhyzzwjr4dq84rxzgqqqqqqyxvqwfn3p9278ttzzpe0e00uhyxhned3j5d9acqak5emwfpflp8z2cngq5rmnrqzpl7890seanrsfqxt8mvn868kxm6ucjprgkk2gu0l0dpcgpq967kd92x6j8qyyelkaky70r4lhjfnqw6u0dslh85etuxc0rtn4luqqej8lv8jmc7gp5wtun4asl4n580ct4zestnthh6tclem8p4fum07sf08jhywmaj07ea3vcx5su8xk722u5m6q355u0xl46r3cqu9yccpawntx388lwxqlunglwx85fklyjpd4gkruqppzkep6jhy8qv63qsfycwzldr0eh7wu6e43g3qacxsmm9de5hszrpvd5kuufwvdhs" + assertTrue(invoiceRequest.isValid()) + val encoded = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqy49sggz0zfj806l5asa7ga4u7gve574q3fylka5mj7zdtvdzy53c9qhzkn0qs93m9w9x074k0260mhepwldegvk738ec3f0l7hzmfec0rrqw2500vvknq7azzdcgjvr75w9hflp9q53ufu02vkzx90mlxmtz6jw4k0z0lnhxkdvzg8kk5xz2qn8ct6tqdrp7j5tams3fghx9p3r5xxd4xj5h4e53ncqsnl8wdv6c0cqygzrf9la07pxj4cs3a9rplvuasawhfuewgyyay826q02xvysqqqqqqgvcqun8zz24uwkkyyrjlj7lewgd08jmr9g6tmspmdfnkujzn7zwy43xspg8hxxqyrluw2lpnmx8qjqvk0kex050vdh4e3yzx3dv53cl776rsszqt4av625d4ywqgfnldmvfu78tl0ynxqa4c7mplw0fjhcds7xh8tlcqpny07c09h3usrguhe8tmplt8gwlsh29nqhxh005h3lnkwr2neklaqj709wgahmylanmzesdfpcwddu54efh5prffc7dlt58rspc2f3sr6axkdzw07uvplex37uv0gnd7fyzm23v8cqzz9djr49wgwqe4zpqjfsu976xlnq" assertEquals(encoded, invoiceRequest.encode()) assertEquals(invoiceRequest, InvoiceRequest.decode(encoded).get()) - assertNull(invoiceRequest.offer.amount) - assertNull(invoiceRequest.offer.description) assertEquals(nodeId, invoiceRequest.offer.issuerId) assertEquals(payerKey.publicKey(), invoiceRequest.payerId) assertEquals(contactSecret, invoiceRequest.contactSecret) assertEquals(payerOffer, invoiceRequest.payerOffer) - assertEquals(payerAddress, invoiceRequest.payerAddress) + } + + @Test + fun `invoice request with contact address`() { + val payerKey = PrivateKey.fromHex("bc8c43b545f07b95a57577a4725065a657fa4831cb95d910970a50eb88949a7e") + val payerAddress = ContactAddress("phoenix", "acinq.co") + val payerOfferKey = PrivateKey.fromHex("2eb661efb156b9fd7f4b8cf3b13cd6ed809d18cf6a38b593ff8d8ec9be2a4db5") + val contactSecret = ByteVector32.fromValidHex("ff2b76ecb569c37ec9090ca54f4b933b0186ee48eab43bb40e17af4d8770a4e9") + val tlvsWithoutSignature = setOf( + InvoiceRequestMetadata(ByteVector.fromHex("a37561651a82fbd68b9c243595f45a9bbb6a906808608497842deb0e24588d61")), + OfferIssuerId(nodeId), + InvoiceRequestAmount(42.msat), + InvoiceRequestPayerId(payerKey.publicKey()), + InvoiceRequestContactSecret(contactSecret), + InvoiceRequestPayerAddress(payerAddress), + ) + val tlvsWithPayerSignature = run { + val signatureTag = ByteVector(("lightning" + "invoice_request" + "invreq_payer_bip_353_signature").encodeToByteArray()) + val signature = signSchnorr(signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerOfferKey) + tlvsWithoutSignature + InvoiceRequestPayerAddressSignature(payerOfferKey.publicKey(), signature) + } + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithPayerSignature)), payerKey) + val tlvs = tlvsWithPayerSignature + Signature(signature) + val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) + assertTrue(invoiceRequest.isValid()) + val encoded = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqy49sggrsehtg7l3jphg6z9mymtz7vrun08h7y40nr3cfqytdswkmax83nc0qs9agk3m5459qfcj566q2hjmla5vvguasm8rvgch64had2gxkqttpzvx360kyvyav4l0gvxlqd5rmjm99shhyazvt26qzn7t4g4g2cfgmlnhxkdvzg8l9dmwedtfcdlvjzgv5485hyemqxrwuj82ksamgrsh4axcwu9ya8l8wdv6c5gswurgdajku6tcppskx6twwyhxxml7wu6e43mpqgjyrc6tvlnz8j96sfh0redhhykftsmu88mtqnlkk79uz5lwjhujn7tyga42vxqfw3qhrc338p694334cktpw5fkkl26xale4uhslhh2aq4cjrfdxp279m44q3k96ly54m6lquwqm9ndfffwyc8ru53d6djq" + assertEquals(encoded, invoiceRequest.encode()) + assertEquals(invoiceRequest, InvoiceRequest.decode(encoded).get()) + assertEquals(nodeId, invoiceRequest.offer.issuerId) + assertEquals(payerKey.publicKey(), invoiceRequest.payerId) + assertEquals(contactSecret, invoiceRequest.contactSecret) + assertEquals(UnverifiedContactAddress(payerAddress, payerOfferKey.publicKey()), invoiceRequest.payerAddress) + } + + @Test + fun `invoice request with invalid contact address`() { + val payerKey = randomKey() + val payerOfferKey = randomKey() + val tlvsWithoutSignature = setOf( + InvoiceRequestMetadata(ByteVector.fromHex("a37561651a82fbd68b9c243595f45a9bbb6a906808608497842deb0e24588d61")), + OfferIssuerId(nodeId), + InvoiceRequestAmount(42.msat), + InvoiceRequestPayerId(payerKey.publicKey()), + InvoiceRequestContactSecret(randomBytes32()), + InvoiceRequestPayerAddress(ContactAddress.fromString("alice@phoenix.co")!!), + ) + + fun signWithPayerOfferKey(tag: ByteVector, priv: PrivateKey): Set { + val signature = signSchnorr(tag, rootHash(TlvStream(tlvsWithoutSignature)), priv) + return tlvsWithoutSignature + InvoiceRequestPayerAddressSignature(payerOfferKey.publicKey(), signature) + } + + run { + val tlvsWithPayerSignature = signWithPayerOfferKey(ByteVector(("lightning" + "invoice_request" + "invreq_payer_bip_353_signature").encodeToByteArray()), payerOfferKey) + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithPayerSignature)), payerKey) + val tlvs = tlvsWithPayerSignature + Signature(signature) + val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) + assertTrue(invoiceRequest.isValid()) + } + run { + val tlvsWithInvalidPayerSignatureTag = signWithPayerOfferKey(ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray()), payerOfferKey) + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithInvalidPayerSignatureTag)), payerKey) + val tlvs = tlvsWithInvalidPayerSignatureTag + Signature(signature) + val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) + assertFalse(invoiceRequest.isValid()) + } + run { + val tlvsWithInvalidPayerSignature = signWithPayerOfferKey(ByteVector(("lightning" + "invoice_request" + "invreq_payer_bip_353_signature").encodeToByteArray()), randomKey()) + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithInvalidPayerSignature)), payerKey) + val tlvs = tlvsWithInvalidPayerSignature + Signature(signature) + val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) + assertFalse(invoiceRequest.isValid()) + } + run { + // Missing payer address signature. + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerKey) + val tlvs = tlvsWithoutSignature + Signature(signature) + val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) + assertTrue(invoiceRequest.isValid()) + assertNull(invoiceRequest.payerAddress) + } } @Test