From f37b370e28bf9b2304a24d2521597f97f681848d Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 17 Oct 2024 09:39:54 +0200 Subject: [PATCH 1/2] Add support for Bolt 12 contacts Add support for contacts as specified in bLIP 42. Contacts are mutually authenticated using a 32-bytes random secret generated when first adding a node to our contacts. When paying contacts, we include our own payment information to allow them to pay us back and us to their contacts. The benefit of this design is that offers stay private by default (they don't include any contact information). It's only when we pay someone we trust that we reveal contact information (which they are free to ignore). The drawback of this design is that if when both nodes independently add each other to their contacts list, they generate a different contact secret: users must manually associate incoming payments to an existing contact to correctly identify incoming payments (by storing multiple secrets for such contacts). This also happens when contacts use multiple wallets, which will all use different contact secrets. I think this is an acceptable trade-off to preserve privacy by default. More details in the bLIP: https://github.com/lightning/blips/pull/42 --- .../kotlin/fr/acinq/lightning/io/Peer.kt | 9 +- .../fr/acinq/lightning/payment/Contacts.kt | 91 +++++++++ .../acinq/lightning/payment/OfferManager.kt | 33 +++- .../lightning/payment/OfferPaymentMetadata.kt | 175 +++++++++++++++--- .../fr/acinq/lightning/wire/OfferTypes.kt | 61 ++++++ .../lightning/payment/ContactsTestsCommon.kt | 60 ++++++ .../payment/OfferManagerTestsCommon.kt | 72 +++++-- .../OfferPaymentMetadataTestsCommon.kt | 102 +++++++++- .../lightning/wire/OfferTypesTestsCommon.kt | 35 ++++ 9 files changed, 580 insertions(+), 58 deletions(-) create mode 100644 modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt create mode 100644 modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index f3636f612..f4ac52b01 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -143,7 +143,7 @@ data class PayInvoice(override val paymentId: UUID, override val amount: MilliSa val paymentHash: ByteVector32 = paymentDetails.paymentHash val recipient: PublicKey = paymentDetails.paymentRequest.nodeId } -data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List? = null) : SendPayment() +data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val contactSecret: ByteVector32?, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List? = null) : SendPayment() // @formatter:on data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand() @@ -826,7 +826,10 @@ class Peer( return res.await() } - suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, fetchInvoiceTimeout: Duration): SendPaymentResult { + /** + * @param contactSecret should only be provided if we'd like to reveal our identity to our contact. + */ + suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, contactSecret: ByteVector32?, fetchInvoiceTimeout: Duration): SendPaymentResult { val res = CompletableDeferred() val paymentId = UUID.randomUUID() this.launch { @@ -837,7 +840,7 @@ class Peer( .first() ) } - send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeout)) + send(PayOffer(paymentId, payerKey, payerNote, amount, offer, contactSecret, fetchInvoiceTimeout)) return res.await() } 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 new file mode 100644 index 000000000..dc4366f9f --- /dev/null +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt @@ -0,0 +1,91 @@ +package fr.acinq.lightning.payment + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.byteVector32 +import fr.acinq.lightning.wire.OfferTypes +import io.ktor.utils.io.core.* + +/** + * BIP 353 human-readable address of a contact. + */ +data class ContactAddress(val name: String, val domain: String) { + init { + require(name.length < 256) { "bip353 name must be smaller than 256 characters" } + require(domain.length < 256) { "bip353 domain must be smaller than 256 characters" } + } + + override fun toString(): String = "$name@$domain" + + companion object { + fun fromString(address: String): ContactAddress? { + val parts = address.replace("₿", "").split('@') + return when { + parts.size != 2 -> null + parts.any { it.length > 255 } -> null + else -> ContactAddress(parts.first(), parts.last()) + } + } + } +} + +/** + * Contact secrets are used to mutually authenticate payments. + * + * The first node to add the other to its contacts list will generate the [primarySecret] and send it when paying. + * If the second node adds the first node to its contacts list from the received payment, it will use the same + * [primarySecret] and both nodes are able to identify payments from each other. + * + * But if the second node independently added the first node to its contacts list, it may have generated a + * different [primarySecret]. Each node has a different [primarySecret], but they will store the other node's + * [primarySecret] in their [additionalRemoteSecrets], which lets them correctly identify payments. + * + * When sending a payment, we must always send the [primarySecret]. + * When receiving payments, we must check if the received contact_secret matches either the [primarySecret] + * or any of the [additionalRemoteSecrets]. + */ +data class ContactSecrets(val primarySecret: ByteVector32, val additionalRemoteSecrets: Set) { + /** + * This function should be used when we attribute an incoming payment to an existing contact. + * This can be necessary when: + * - our contact added us without using the contact_secret we initially sent them + * - our contact is using a different wallet from the one(s) we have already stored + */ + fun addRemoteSecret(remoteSecret: ByteVector32): ContactSecrets { + return this.copy(additionalRemoteSecrets = additionalRemoteSecrets + remoteSecret) + } +} + +/** + * Contacts are trusted people to which we may want to reveal our identity when paying them. + * We're also able to figure out when incoming payments have been made by one of our contacts. + * See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + */ +object Contacts { + + /** + * We derive our contact secret deterministically based on our offer and our contact's offer. + * This provides a few interesting properties: + * - if we remove a contact and re-add it using the same offer, we will generate the same contact secret + * - if our contact is using the same deterministic algorithm with a single static offer, they will also generate the same contact secret + * + * Note that this function must only be used when adding a contact that hasn't paid us before. + * If we're adding a contact that paid us before, we must use the contact_secret they sent us, + * which ensures that when we pay them, they'll be able to know it was coming from us (see + * [fromRemoteSecret]). + */ + fun computeContactSecret(ourOffer: OfferTypes.OfferAndKey, theirOffer: OfferTypes.Offer): ContactSecrets { + // If their offer doesn't contain an issuerId, it must contain blinded paths. + val offerNodeId = theirOffer.issuerId ?: theirOffer.paths?.first()?.nodeId!! + val ecdh = offerNodeId.times(ourOffer.privateKey) + val primarySecret = Crypto.sha256("blip42_contact_secret".toByteArray() + ecdh.value.toByteArray()).byteVector32() + return ContactSecrets(primarySecret, setOf()) + } + + /** + * When adding a contact from which we've received a payment, we must use the contact_secret + * they sent us: this ensures that they'll be able to identify payments coming from us. + */ + fun fromRemoteSecret(remoteSecret: ByteVector32): ContactSecrets = ContactSecrets(remoteSecret, setOf()) + +} \ No newline at end of file diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt index f04d01a12..38a4a6445 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt @@ -49,7 +49,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v * @return invoice requests that must be sent and the corresponding path_id that must be used in case of a timeout. */ fun requestInvoice(payOffer: PayOffer): Triple, OfferTypes.InvoiceRequest> { - val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash) + // If we're providing our contact secret, it means we're willing to reveal our identity to the recipient. + // We include our own offer to allow them to add us to their contacts list and pay us back. + val contactTlvs = setOfNotNull( + payOffer.contactSecret?.let { OfferTypes.InvoiceRequestContactSecret(it) }, + payOffer.contactSecret?.let { OfferTypes.InvoiceRequestPayerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer) }, + ) + val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash, contactTlvs) val replyPathId = randomBytes32() pendingInvoiceRequests[replyPathId] = PendingInvoiceRequest(payOffer, request) // We add dummy hops to the reply path: this way the receiver only learns that we're at most 3 hops away from our peer. @@ -130,7 +136,14 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v } } - private fun receiveInvoiceRequest(request: OfferTypes.InvoiceRequest, pathId: ByteVector?, blindedPrivateKey: PrivateKey, replyPath: RouteBlinding.BlindedRoute?, remoteChannelUpdates: List, currentBlockHeight: Int): OnionMessageAction.SendMessage? { + private fun receiveInvoiceRequest( + request: OfferTypes.InvoiceRequest, + pathId: ByteVector?, + blindedPrivateKey: PrivateKey, + replyPath: RouteBlinding.BlindedRoute?, + remoteChannelUpdates: List, + currentBlockHeight: Int + ): OnionMessageAction.SendMessage? { // We must use the most restrictive minimum HTLC value between local and remote. val minHtlc = (listOf(nodeParams.htlcMinimum) + remoteChannelUpdates.map { it.htlcMinimumMsat }).max() return when { @@ -155,7 +168,16 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v val preimage = randomBytes32() val (truncatedPayerNote, truncatedDescription) = OfferPaymentMetadata.truncateNotes(request.payerNote, request.offer.description) val expirySeconds = request.offer.expirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds - val metadata = OfferPaymentMetadata.V2( + // We mustn't use too much space in the path_id, otherwise the sender won't be able to include it in its payment onion. + // If the payer_address is provided, we don't include the payer_offer: we can retrieve it from the DNS. + // Otherwise, we want to include the payer_offer, but we must skip it if it's too large. + val payerOfferSize = request.payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records).size } + val payerOffer = when { + request.payerAddress != null -> null + payerOfferSize != null && payerOfferSize > 300 -> null + else -> request.payerOffer + } + val metadata = OfferPaymentMetadata.V3( offerId = request.offer.offerId, amount = amount, preimage = preimage, @@ -164,7 +186,10 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v description = truncatedDescription, payerKey = request.payerId, payerNote = truncatedPayerNote, - quantity = request.quantity_opt + quantity = request.quantity_opt, + contactSecret = request.contactSecret, + payerOffer = payerOffer, + payerAddress = request.payerAddress, ).toPathId(nodeParams.nodePrivateKey) val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(metadata))).write().toByteVector() val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta 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 2087426a5..981855804 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 @@ -9,6 +9,7 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.crypto.ChaCha20Poly1305 import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.LightningCodecs +import fr.acinq.lightning.wire.OfferTypes import kotlin.experimental.and import kotlin.experimental.or @@ -44,10 +45,21 @@ sealed class OfferPaymentMetadata { when (this) { is V1 -> this.write(out) is V2 -> this.write(out) + is V3 -> this.write(out) } return out.toByteArray().byteVector() } + private fun encodeWithoutVersion(): ByteArray { + val out = ByteArrayOutput() + when (this) { + is V1 -> this.write(out) + is V2 -> this.write(out) + is V3 -> this.write(out) + } + return out.toByteArray() + } + /** Encode into a path_id that must be included in the [Bolt12Invoice]'s blinded path. */ fun toPathId(nodeKey: PrivateKey): ByteVector = when (this) { is V1 -> { @@ -57,22 +69,15 @@ sealed class OfferPaymentMetadata { } is V2 -> { // We only encrypt what comes after the version byte. - val encoded = run { - val out = ByteArrayOutput() - this.write(out) - out.toByteArray() - } - val (encrypted, mac) = run { - val paymentHash = Crypto.sha256(this.preimage).byteVector32() - val metadataKey = V2.deriveKey(nodeKey, paymentHash) - val nonce = paymentHash.take(12).toByteArray() - ChaCha20Poly1305.encrypt(metadataKey.value.toByteArray(), nonce, encoded, paymentHash.toByteArray()) - } - val out = ByteArrayOutput() - out.write(2) // version - out.write(encrypted) - out.write(mac) - out.toByteArray().byteVector() + val encoded = encodeWithoutVersion() + val metadataKey = V2.deriveKey(nodeKey, paymentHash) + encrypt(encoded, version, metadataKey, paymentHash) + } + is V3 -> { + // We only encrypt what comes after the version byte. + val encoded = encodeWithoutVersion() + val metadataKey = V3.deriveKey(nodeKey, paymentHash) + encrypt(encoded, version, metadataKey, paymentHash) } } @@ -189,6 +194,100 @@ sealed class OfferPaymentMetadata { } } + /** In this version, we encrypt the payment metadata with a key derived from our seed. */ + data class V3( + override val offerId: ByteVector32, + override val amount: MilliSatoshi, + override val preimage: ByteVector32, + override val createdAtSeconds: Long, + override val relativeExpirySeconds: Long?, + val description: String?, + override val payerKey: PublicKey?, + override val payerNote: String?, + val quantity: Long?, + val contactSecret: ByteVector32?, + val payerOffer: OfferTypes.Offer?, + val payerAddress: ContactAddress?, + ) : OfferPaymentMetadata() { + override val version: Byte get() = 3 + + fun write(out: Output) { + LightningCodecs.writeBytes(offerId, out) + LightningCodecs.writeBigSize(amount.toLong(), out) + LightningCodecs.writeBytes(preimage, out) + LightningCodecs.writeBigSize(createdAtSeconds, out) + + // We use bit flags to track which nullable fields are provided. + var flags = 0 + if (relativeExpirySeconds != null) flags = flags or 0b00001 + if (payerKey != null) flags = flags or 0b00010 + if (quantity != null) flags = flags or 0b00100 + if (description != null) flags = flags or 0b01000 + if (payerNote != null) flags = flags or 0b10000 + if (contactSecret != null) flags = flags or 0b100000 + if (payerOffer != null) flags = flags or 0b1000000 + if (payerAddress != null) flags = flags or 0b10000000 + LightningCodecs.writeByte(flags, out) + + relativeExpirySeconds?.let { LightningCodecs.writeBigSize(it, out) } + payerKey?.let { LightningCodecs.writeBytes(it.value, out) } + quantity?.let { LightningCodecs.writeBigSize(it, out) } + description?.let { + val encodedDescription = it.encodeToByteArray() + LightningCodecs.writeBigSize(encodedDescription.size.toLong(), out) + LightningCodecs.writeBytes(encodedDescription, out) + } + payerNote?.let { + val encodedPayerNote = it.encodeToByteArray() + LightningCodecs.writeBigSize(encodedPayerNote.size.toLong(), out) + LightningCodecs.writeBytes(encodedPayerNote, out) + } + contactSecret?.let { LightningCodecs.writeBytes(it, out) } + payerOffer?.let { LightningCodecs.writeBytes(OfferTypes.Offer.tlvSerializer.write(it.records), out) } + payerAddress?.let { LightningCodecs.writeBytes(it.toString().encodeToByteArray(), out) } + } + + companion object { + fun read(input: Input): V3 { + val offerId = LightningCodecs.bytes(input, 32).byteVector32() + val amount = LightningCodecs.bigSize(input).msat + val preimage = LightningCodecs.bytes(input, 32).byteVector32() + val createdAtSeconds = LightningCodecs.bigSize(input) + // Bit flags indicate which fields are provided. + val flags = LightningCodecs.byte(input) + val hasRelativeExpiry = (flags and 0b00001) != 0 + val hasPayerKey = (flags and 0b00010) != 0 + val hasQuantity = (flags and 0b00100) != 0 + val hasDescription = (flags and 0b01000) != 0 + val hasPayerNote = (flags and 0b10000) != 0 + val hasContactSecret = (flags and 0b100000) != 0 + val hasPayerOffer = (flags and 0b1000000) != 0 + val hasPayerAddress = (flags and 0b10000000) != 0 + // We can now read nullable fields. + val relativeExpirySeconds = if (hasRelativeExpiry) LightningCodecs.bigSize(input) else null + val payerKey = if (hasPayerKey) PublicKey(LightningCodecs.bytes(input, 33)) else null + val quantity = if (hasQuantity) LightningCodecs.bigSize(input) else null + val description = when { + hasDescription -> LightningCodecs.bytes(input, LightningCodecs.bigSize(input).toInt()).decodeToString() + else -> null + } + val payerNote = when { + hasPayerNote -> LightningCodecs.bytes(input, LightningCodecs.bigSize(input).toInt()).decodeToString() + else -> null + } + val contactSecret = if (hasContactSecret) LightningCodecs.bytes(input, 32).byteVector32() else null + val payerOffer = if (hasPayerOffer) OfferTypes.Offer(OfferTypes.Offer.tlvSerializer.read(input)) else null + val payerAddress = if (hasPayerAddress) ContactAddress.fromString(LightningCodecs.bytes(input, input.availableBytes).decodeToString()) else null + return V3(offerId, amount, preimage, createdAtSeconds, relativeExpirySeconds, description, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress) + } + + fun deriveKey(nodeKey: PrivateKey, paymentHash: ByteVector32): PrivateKey { + val tweak = Crypto.sha256("offer_payment_metadata_v3".encodeToByteArray() + paymentHash.toByteArray() + nodeKey.value.toByteArray()) + return nodeKey * PrivateKey(tweak) + } + } + } + companion object { /** * Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB). @@ -199,10 +298,31 @@ sealed class OfferPaymentMetadata { return when (val version = LightningCodecs.byte(input)) { 1 -> V1.read(input) 2 -> V2.read(input) + 3 -> V3.read(input) else -> throw IllegalArgumentException("unknown offer payment metadata version: $version") } } + private fun encrypt(encoded: ByteArray, version: Byte, metadataKey: PrivateKey, paymentHash: ByteVector32): ByteVector { + val (encrypted, mac) = run { + val nonce = paymentHash.take(12).toByteArray() + ChaCha20Poly1305.encrypt(metadataKey.value.toByteArray(), nonce, encoded, paymentHash.toByteArray()) + } + val out = ByteArrayOutput() + out.write(version.toInt()) + out.write(encrypted) + out.write(mac) + return out.toByteArray().byteVector() + } + + private fun decrypt(input: ByteArrayInput, metadataKey: PrivateKey, paymentHash: ByteVector32): ByteArray { + val nonce = paymentHash.take(12).toByteArray() + val encryptedSize = input.availableBytes - 16 + val encrypted = LightningCodecs.bytes(input, encryptedSize) + val mac = LightningCodecs.bytes(input, 16) + return ChaCha20Poly1305.decrypt(metadataKey.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac) + } + /** * Decode an [OfferPaymentMetadata] stored in a blinded path's path_id field. * @return null if the path_id doesn't contain valid data created by us. @@ -221,18 +341,17 @@ sealed class OfferPaymentMetadata { // This call is safe since we verified that we have the right number of bytes and the signature was valid. V1.read(ByteArrayInput(metadata)) } - 2 -> { - val metadataKey = V2.deriveKey(nodeKey, paymentHash) - val nonce = paymentHash.take(12).toByteArray() - val encryptedSize = input.availableBytes - 16 - try { - val encrypted = LightningCodecs.bytes(input, encryptedSize) - val mac = LightningCodecs.bytes(input, 16) - val decrypted = ChaCha20Poly1305.decrypt(metadataKey.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac) - V2.read(ByteArrayInput(decrypted)) - } catch (_: Throwable) { - null - } + 2 -> try { + val decrypted = decrypt(input, V2.deriveKey(nodeKey, paymentHash), paymentHash) + V2.read(ByteArrayInput(decrypted)) + } catch (_: Throwable) { + null + } + 3 -> try { + val decrypted = decrypt(input, V3.deriveKey(nodeKey, paymentHash), paymentHash) + V3.read(ByteArrayInput(decrypted)) + } catch (_: Throwable) { + null } else -> null } 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 6157f8983..0b46be952 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 @@ -17,6 +17,7 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.message.OnionMessages import fr.acinq.lightning.utils.toByteVector +import fr.acinq.lightning.payment.ContactAddress /** * Lightning Bolt 12 offers @@ -415,6 +416,57 @@ object OfferTypes { } } + /** + * When paying one of our contacts, this contains the secret that lets them detect that the payment came from us. + * See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + */ + data class InvoiceRequestContactSecret(val contactSecret: ByteVector32) : InvoiceRequestTlv() { + override val tag: Long get() = InvoiceRequestContactSecret.tag + override fun write(out: Output) = LightningCodecs.writeBytes(contactSecret, out) + + companion object : TlvValueReader { + const val tag: Long = 2_000_001_729L + override fun read(input: Input): InvoiceRequestContactSecret = InvoiceRequestContactSecret(LightningCodecs.bytes(input, 32).byteVector32()) + } + } + + /** + * When paying one of our contacts, we may include our offer to allow them to pay us back and add us to their contacts. + * See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + */ + data class InvoiceRequestPayerOffer(val offer: Offer) : InvoiceRequestTlv() { + override val tag: Long get() = InvoiceRequestPayerOffer.tag + override fun write(out: Output) = LightningCodecs.writeBytes(Offer.tlvSerializer.write(offer.records), out) + + companion object : TlvValueReader { + const val tag: Long = 2_000_001_731L + override fun read(input: Input): InvoiceRequestPayerOffer = InvoiceRequestPayerOffer(Offer(Offer.tlvSerializer.read(LightningCodecs.bytes(input, input.availableBytes)))) + } + } + + /** + * When paying one of our contacts, we may include our BIP 353 address to allow them to pay us back and add us to their contacts. + * See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + */ + data class InvoiceRequestPayerAddress(val address: ContactAddress) : InvoiceRequestTlv() { + override val tag: Long get() = InvoiceRequestPayerAddress.tag + override fun write(out: Output) { + LightningCodecs.writeByte(address.name.length, out) + LightningCodecs.writeBytes(address.name.encodeToByteArray(), out) + LightningCodecs.writeByte(address.domain.length, out) + LightningCodecs.writeBytes(address.domain.encodeToByteArray(), out) + } + + companion object : TlvValueReader { + const val tag: Long = 2_000_001_733L + override fun read(input: Input): InvoiceRequestPayerAddress { + val name = LightningCodecs.bytes(input, LightningCodecs.byte(input)).decodeToString() + val domain = LightningCodecs.bytes(input, LightningCodecs.byte(input)).decodeToString() + return InvoiceRequestPayerAddress(ContactAddress(name, domain)) + } + } + } + /** * Payment paths to send the payment to. */ @@ -889,6 +941,9 @@ object OfferTypes { val requestedAmount: MilliSatoshi = amount ?: (offer.amount!! * quantity) val payerId: PublicKey = records.get()!!.publicKey val payerNote: String? = records.get()?.note + val contactSecret: ByteVector32? = records.get()?.contactSecret + val payerOffer: Offer? = records.get()?.offer + val payerAddress: ContactAddress? = records.get()?.address private val signature: ByteVector64 = records.get()!!.signature fun isValid(): Boolean = @@ -989,6 +1044,9 @@ object OfferTypes { InvoiceRequestQuantity.tag to InvoiceRequestQuantity as TlvValueReader, InvoiceRequestPayerId.tag to InvoiceRequestPayerId as TlvValueReader, InvoiceRequestPayerNote.tag to InvoiceRequestPayerNote as TlvValueReader, + InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader, + InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader, + InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader, Signature.tag to Signature as TlvValueReader, ) ) @@ -1028,6 +1086,9 @@ object OfferTypes { InvoiceRequestQuantity.tag to InvoiceRequestQuantity as TlvValueReader, InvoiceRequestPayerId.tag to InvoiceRequestPayerId as TlvValueReader, InvoiceRequestPayerNote.tag to InvoiceRequestPayerNote as TlvValueReader, + InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader, + InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader, + InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress 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 new file mode 100644 index 000000000..da4f8f9aa --- /dev/null +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt @@ -0,0 +1,60 @@ +package fr.acinq.lightning.payment + +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.tests.TestConstants +import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.lightning.wire.TlvStream +import kotlin.test.Test +import kotlin.test.assertEquals + +class ContactsTestsCommon : LightningTestSuite() { + + @Test + fun `derive deterministic contact secret -- official test vectors`() { + // See https://github.com/lightning/blips/blob/master/blip-0042.md + val trampolineNodeId = PublicKey.fromHex("02f40ffcf9991911063c6fe5a2e48aa31801ba37f18c303d1abe7942e61adcc5e2") + val aliceOfferAndKey = TestConstants.Alice.nodeParams.defaultOffer(trampolineNodeId) + assertEquals("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb", aliceOfferAndKey.privateKey.value.toHex()) + assertEquals("0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9", aliceOfferAndKey.privateKey.publicKey().toHex()) + assertEquals( + "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h", + aliceOfferAndKey.offer.encode() + ) + run { + // Offers that don't contain an issuer_id. + val bobOfferAndKey = TestConstants.Bob.nodeParams.defaultOffer(trampolineNodeId) + assertEquals("12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333", bobOfferAndKey.privateKey.value.toHex()) + assertEquals("035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34", bobOfferAndKey.privateKey.publicKey().toHex()) + assertEquals( + "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj", + bobOfferAndKey.offer.encode(), + ) + val contactSecretAlice = Contacts.computeContactSecret(aliceOfferAndKey, bobOfferAndKey.offer) + assertEquals("810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8", contactSecretAlice.primarySecret.toHex()) + val contactSecretBob = Contacts.computeContactSecret(bobOfferAndKey, aliceOfferAndKey.offer) + assertEquals(contactSecretAlice, contactSecretBob) + } + run { + // The remote offer contains an issuer_id and a blinded path. + val issuerKey = PrivateKey.fromHex("bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845") + assertEquals("023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6", issuerKey.publicKey().toHex()) + val bobOffer = run { + val defaultOffer = TestConstants.Bob.nodeParams.defaultOffer(trampolineNodeId).offer + defaultOffer.copy(records = TlvStream(defaultOffer.records.records + OfferTypes.OfferIssuerId(issuerKey.publicKey()))) + } + assertEquals(issuerKey.publicKey(), bobOffer.issuerId) + assertEquals( + "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx", + bobOffer.encode() + ) + val contactSecretAlice = Contacts.computeContactSecret(aliceOfferAndKey, bobOffer) + assertEquals("4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c", contactSecretAlice.primarySecret.toHex()) + val contactSecretBob = Contacts.computeContactSecret(OfferTypes.OfferAndKey(bobOffer, issuerKey), aliceOfferAndKey.offer) + assertEquals(contactSecretAlice, contactSecretBob) + + } + } + +} \ No newline at end of file diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index fe66f7e53..e8c9c7f04 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -20,10 +20,7 @@ import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.tests.utils.testLoggerFactory import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.wire.MessageOnion -import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.lightning.wire.OnionMessage -import fr.acinq.lightning.wire.RouteBlindingEncryptedData +import fr.acinq.lightning.wire.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlin.test.* @@ -51,13 +48,13 @@ class OfferManagerTestsCommon : LightningTestSuite() { return Pair(OnionMessage(relayInfo.nextPathKeyOverride ?: nextBlinding, decrypted.value.nextPacket), nextNode) } - private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V2 { + private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V3 { val blindedRoute = invoice.blindedPaths.first().route.route assertEquals(2, blindedRoute.encryptedPayloads.size) val (_, nextBlinding) = RouteBlinding.decryptPayload(trampolineKey, blindedRoute.firstPathKey, blindedRoute.encryptedPayloads.first()).right!! val (lastPayload, _) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, nextBlinding, blindedRoute.encryptedPayloads.last()).right!! val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!! - return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodePrivateKey, pathId, invoice.paymentHash) as OfferPaymentMetadata.V2 + return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodePrivateKey, pathId, invoice.paymentHash) as OfferPaymentMetadata.V3 } @Test @@ -69,7 +66,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Bob sends an invoice request to Alice. val currentBlockHeight = 0 - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) assertEquals(invoiceRequests.size, 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -103,7 +100,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).offer // Bob sends an invoice request to Alice. - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) assertEquals(invoiceRequests.size, 1) val (messageForAliceTrampoline, nextNodeAliceTrampoline) = trampolineRelay(invoiceRequests.first(), bobTrampolineKey) @@ -136,7 +133,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "amountless offer").offer // Bob sends an invoice request to Alice. - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (invoiceRequestPathId, invoiceRequests, request) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) // The invoice request times out. @@ -158,7 +155,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "deterministic amountless offer").offer // Bob sends two invoice requests to Alice. - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) // Alice sends two invoices back to Bob. @@ -184,7 +181,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { run { // Bob sends an invalid invoice request to Alice. val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).offer - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) assertNull(aliceOfferManager.receiveMessage(messageForAlice.copy(pathKey = randomKey().publicKey()), listOf(), 0)) @@ -201,7 +198,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { blindedPathSessionKey = randomKey(), pathId = randomBytes32() ).offer - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) assertNull(aliceOfferManager.receiveMessage(messageForAlice, listOf(), 0)) @@ -216,7 +213,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "tip").offer // Bob sends an invoice request to Alice. - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) // Alice sends an invalid response back to Bob. @@ -234,7 +231,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 50_000.msat, "coffee").offer // Bob sends an invoice request to Alice that pays less than the offer amount. - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 40_000.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 40_000.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) // Alice sends an invoice error back to Bob. @@ -255,7 +252,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).offer // Bob sends an invoice request to Alice that pays less than the minimum htlc amount. - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 10.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 10.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) // Alice sends an invoice error back to Bob. @@ -277,7 +274,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Bob sends an invoice request to Alice. val payerNote = "Thanks for all the fish" - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) assertEquals(invoiceRequests.size, 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -306,7 +303,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Bob sends an invoice request to Alice. val payerNote = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, 20.seconds) + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, null, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) assertEquals(invoiceRequests.size, 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -328,4 +325,45 @@ class OfferManagerTestsCommon : LightningTestSuite() { assertEquals(48, metadata.payerNote.encodeToByteArray().size) assertEquals(metadata.payerNote, payerNote.take(45) + "…") } + + @Test + fun `pay offer with contact details`() = runSuspendTest { + // Alice and Bob use the same trampoline node. + val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) + val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "I'm a nice contact").offer + + // Bob sends an invoice request to Alice including contact details. + val contactSecret = randomBytes32() + val payerNote = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, contactSecret, 20.seconds) + val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) + assertEquals(invoiceRequests.size, 1) + val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) + assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Alice.nodeParams.nodeId)), nextNodeAlice) + // Alice sends an invoice back to Bob. + val invoiceResponse = aliceOfferManager.receiveMessage(messageForAlice, listOf(), 0) + assertIs(invoiceResponse) + val (messageForBob, nextNodeBob) = trampolineRelay(invoiceResponse.message, aliceTrampolineKey) + assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Bob.nodeParams.nodeId)), nextNodeBob) + val payInvoice = bobOfferManager.receiveMessage(messageForBob, listOf(), 0) + assertIs(payInvoice) + assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first()) + assertEquals(payOffer, payInvoice.payOffer) + assertEquals(contactSecret, payInvoice.invoice.invoiceRequest.contactSecret) + assertNotNull(payInvoice.invoice.invoiceRequest.payerOffer) + + // We include contact details in the payment metadata. + val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey) + assertEquals(metadata.description, "I'm a nice contact") + assertEquals(44, metadata.payerNote?.length) + assertEquals(contactSecret, metadata.contactSecret) + assertEquals(metadata.payerOffer, TestConstants.Bob.nodeParams.defaultOffer(aliceWalletParams.trampolineNode.id).offer) + + // When using trampoline, we must be able to include the invoice's blinded paths in our trampoline onion. + // The total onion can be at most 1300 bytes: we allow at most 750 bytes to be used by the blinded paths. + val trampolineOnionPathField = OnionPaymentPayloadTlv.OutgoingBlindedPaths(payInvoice.invoice.blindedPaths) + assertTrue(trampolineOnionPathField.write().size < 750) + } + } \ No newline at end of file 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 2ea73e115..03a369b20 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 @@ -1,9 +1,16 @@ package fr.acinq.lightning.payment +import fr.acinq.bitcoin.Block import fr.acinq.bitcoin.ByteVector +import fr.acinq.lightning.Feature +import fr.acinq.lightning.FeatureSupport +import fr.acinq.lightning.Features import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.wire.OfferTypes +import kotlin.experimental.xor import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -140,6 +147,67 @@ class OfferPaymentMetadataTestsCommon { assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } + @Test + fun `encode - decode v3 metadata`() { + val nodeKey = randomKey() + val preimage = randomBytes32() + val metadata = OfferPaymentMetadata.V3( + offerId = randomBytes32(), + amount = 200_000_000.msat, + preimage = preimage, + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = null, + payerKey = randomKey().publicKey(), + payerNote = "thanks for all the fish", + quantity = 1, + contactSecret = null, + payerOffer = null, + payerAddress = null, + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertTrue(pathId.size() in 125..175) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) + } + + @Test + fun `encode - decode v3 metadata with contact information`() { + val nodeKey = randomKey() + val preimage = randomBytes32() + val payerOffer = OfferTypes.Offer.createBlindedOffer( + chainHash = Block.LivenetGenesisBlock.hash, + nodePrivateKey = TestConstants.Alice.nodeParams.nodePrivateKey, + trampolineNodeId = randomKey().publicKey(), + amount = null, + description = null, + features = Features( + Feature.VariableLengthOnion to FeatureSupport.Optional, + Feature.PaymentSecret to FeatureSupport.Optional, + Feature.BasicMultiPartPayment to FeatureSupport.Optional, + ), + blindedPathSessionKey = randomKey() + ).offer + val metadata = OfferPaymentMetadata.V3( + offerId = randomBytes32(), + amount = 200_000_000.msat, + preimage = preimage, + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = null, + payerKey = randomKey().publicKey(), + payerNote = "hello there", + quantity = 1, + contactSecret = randomBytes32(), + payerOffer = payerOffer, + payerAddress = null, + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertTrue(pathId.size() in 350..400) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) + } + @Test fun `truncate long description or payerNote`() { val longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." @@ -176,24 +244,46 @@ class OfferPaymentMetadataTestsCommon { @Test fun `decode invalid path_id`() { val nodeKey = randomKey() - val metadata = OfferPaymentMetadata.V1( + val preimage = randomBytes32() + val paymentHash = preimage.sha256() + val metadataV1 = OfferPaymentMetadata.V1( offerId = randomBytes32(), amount = 50_000_000.msat, - preimage = randomBytes32(), + preimage = preimage, payerKey = randomKey().publicKey(), payerNote = null, quantity = 1, createdAtMillis = 0 ) + val metadataV3 = OfferPaymentMetadata.V3( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = preimage, + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = null, + payerKey = randomKey().publicKey(), + payerNote = null, + quantity = 1, + contactSecret = randomBytes32(), + payerOffer = null, + payerAddress = null, + ) val testCases = listOf( ByteVector.empty, ByteVector("02deadbeef"), // invalid version - metadata.toPathId(nodeKey).dropRight(1), // not enough bytes - metadata.toPathId(nodeKey).concat(ByteVector("deadbeef")), // too many bytes - metadata.toPathId(randomKey()), // signed with different key + metadataV1.toPathId(nodeKey).dropRight(1), // not enough bytes + metadataV1.toPathId(nodeKey).concat(ByteVector("deadbeef")), // too many bytes + metadataV1.toPathId(randomKey()), // signed with different key + metadataV3.toPathId(nodeKey).drop(1), // missing bytes at the start + metadataV3.toPathId(nodeKey).dropRight(1), // missing bytes at the end + metadataV3.toPathId(nodeKey).let { it.update(13, it[13].xor(0xff.toByte())) }, // modified bytes + metadataV3.toPathId(nodeKey).concat(ByteVector("deadbeef")), // additional bytes + metadataV3.toPathId(randomKey()), // encrypted with different key ) testCases.forEach { - assertNull(OfferPaymentMetadata.fromPathId(nodeKey, it, metadata.paymentHash)) + assertNull(OfferPaymentMetadata.fromPathId(nodeKey, it, paymentHash)) } } + } \ No newline at end of file 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 7775a4f11..2068d3af4 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 @@ -11,6 +11,7 @@ import fr.acinq.lightning.Lightning.randomKey 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.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.testLoggerFactory @@ -20,8 +21,11 @@ import fr.acinq.lightning.wire.OfferTypes.ContactInfo.BlindedPath import fr.acinq.lightning.wire.OfferTypes.InvoiceRequest import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestAmount 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.InvoiceRequestPayerId +import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestPayerOffer import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestQuantity import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestTlv import fr.acinq.lightning.wire.OfferTypes.Offer @@ -222,6 +226,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { val tlvs = tlvsWithoutSignature + Signature(signature) val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gpzwyzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgzhxcvjdh925x0jyyxzrdc5s2mwqtmpf4zezd7mg094lmcwqh3xyw2n6jdzkl80jj2euh48s00wtgad9j7wyt67rnth3dqq0fa0usrxm" + assertEquals(encoded, invoiceRequest.encode()) assertEquals(invoiceRequest, InvoiceRequest.decode(encoded).get()) assertNull(invoiceRequest.offer.amount) assertNull(invoiceRequest.offer.description) @@ -238,6 +243,36 @@ class OfferTypesTestsCommon : LightningTestSuite() { } } + @Test + fun `invoice request with contact info`() { + 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(10_000.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 = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqgn3qkppqfufxgalt7nkrherkhnepnxn65z9yn7mknwtcf4d35gjj8q5zu26duzqxgehamhuaj6ly9twcfwsdu95swqpl0z9cep4yq2j0qpqa0d3tdkk0877lg60jtmpq2yss42jayrhuekkd6rmzjn94clglr8lsk8lx9l7wu6e4sfq766scfgzvlp0fvp5v86230hwz99zuc5xywscek562j7hxjx0qzz0uae4ntplqq3qgdyhl4lcy62hzz855v8annkr46a8n9eqsn5satgpagesjqqqqqqppnqrjvugf2h366csswt7tml9ep4u7tvv4rf0wq8d4xwmjg20cfcjky6q9q7uccqs0l3etux0vcuzgpje7mye73a3k7hxysg694jj8rlmmgwzqgpwh4nf23k53cppx0ahd38nca0aujvcrkhrmv8aeax2lpkrc6ua0lqqxv3lmpuk78jqdrjlya0v8avapm7zagkvzu6aa7j787wecd20xml5zteu4erklvnlk0vtxp4y8pe4hjjh9x7syd98rehawsuwq8pfxxq0t56e5felm3s8ly68m33azdheystd29slqqgg4kgw54epcrx5gyzfxrshmgm7dlnhxkdv2yg8wp5x7etwd9uqsctrd9h8ztnrdu" + 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 `compute merkle tree root`() { data class TestCase(val tlvs: String, val count: Int, val expected: ByteVector32) From 8d095f08c893d6ad9002fbcffa93b585ff0389a3 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 7 Jan 2025 16:57:59 +0100 Subject: [PATCH 2/2] Add signature when including `invreq_bip_353_name` When including a BIP 353 HRN, we also require including a signature of the `invoice_request` using one of the keys from the offer stored in the BIP 353 DNS record. We only add the BIP 353 HRN to our contacts list after verifying that it matches the offer we retrieved. --- .../fr/acinq/lightning/payment/Contacts.kt | 21 +++++ .../lightning/payment/OfferPaymentMetadata.kt | 15 ++- .../fr/acinq/lightning/wire/OfferTypes.kt | 49 ++++++++-- .../lightning/payment/ContactsTestsCommon.kt | 8 +- .../OfferPaymentMetadataTestsCommon.kt | 26 ++++- .../lightning/wire/OfferTypesTestsCommon.kt | 94 +++++++++++++++++-- 6 files changed, 195 insertions(+), 18 deletions(-) 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 981855804..359fe9c93 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 @@ -207,7 +207,7 @@ sealed class OfferPaymentMetadata { val quantity: Long?, val contactSecret: ByteVector32?, val payerOffer: OfferTypes.Offer?, - val payerAddress: ContactAddress?, + val payerAddress: UnverifiedContactAddress?, ) : OfferPaymentMetadata() { override val version: Byte get() = 3 @@ -244,7 +244,10 @@ sealed class OfferPaymentMetadata { } contactSecret?.let { LightningCodecs.writeBytes(it, out) } payerOffer?.let { LightningCodecs.writeBytes(OfferTypes.Offer.tlvSerializer.write(it.records), out) } - payerAddress?.let { LightningCodecs.writeBytes(it.toString().encodeToByteArray(), out) } + payerAddress?.let { + LightningCodecs.writeBytes(it.address.toString().encodeToByteArray(), out) + LightningCodecs.writeBytes(it.expectedOfferSigningKey.value, out) + } } companion object { @@ -277,7 +280,13 @@ sealed class OfferPaymentMetadata { } val contactSecret = if (hasContactSecret) LightningCodecs.bytes(input, 32).byteVector32() else null val payerOffer = if (hasPayerOffer) OfferTypes.Offer(OfferTypes.Offer.tlvSerializer.read(input)) else null - val payerAddress = if (hasPayerAddress) ContactAddress.fromString(LightningCodecs.bytes(input, input.availableBytes).decodeToString()) else null + val payerAddress = when { + hasPayerAddress -> ContactAddress.fromString(LightningCodecs.bytes(input, input.availableBytes - 33).decodeToString())?.let { + val offerKey = PublicKey(LightningCodecs.bytes(input, 33)) + UnverifiedContactAddress(it, offerKey) + } + else -> null + } return V3(offerId, amount, preimage, createdAtSeconds, relativeExpirySeconds, description, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress) } 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 0b46be952..86063384b 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 @@ -16,8 +16,9 @@ import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.message.OnionMessages -import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.payment.ContactAddress +import fr.acinq.lightning.payment.UnverifiedContactAddress +import fr.acinq.lightning.utils.toByteVector /** * Lightning Bolt 12 offers @@ -467,6 +468,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. */ @@ -943,7 +966,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 = @@ -952,8 +975,20 @@ 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() + 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 { @@ -1014,10 +1049,10 @@ object OfferTypes { is Left -> return Left(offer.value) is Right -> {} } - if (records.get() == null) return Left(MissingRequiredTlv(0)) - if (records.get() == null && records.get() == null) return Left(MissingRequiredTlv(82)) - 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)) } @@ -1047,6 +1082,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, ) ) @@ -1089,6 +1125,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 03a369b20..c548a5cbf 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 @@ -172,7 +172,7 @@ class OfferPaymentMetadataTestsCommon { } @Test - fun `encode - decode v3 metadata with contact information`() { + fun `encode - decode v3 metadata with contact offer`() { val nodeKey = randomKey() val preimage = randomBytes32() val payerOffer = OfferTypes.Offer.createBlindedOffer( @@ -208,6 +208,30 @@ class OfferPaymentMetadataTestsCommon { assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } + @Test + fun `encode - decode v3 metadata with contact address`() { + val nodeKey = randomKey() + val preimage = randomBytes32() + val metadata = OfferPaymentMetadata.V3( + offerId = randomBytes32(), + amount = 200_000_000.msat, + preimage = preimage, + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = null, + payerKey = randomKey().publicKey(), + payerNote = "hello there", + quantity = 1, + contactSecret = randomBytes32(), + payerOffer = null, + payerAddress = UnverifiedContactAddress(ContactAddress.fromString("alice@acinq.co")!!, randomKey().publicKey()), + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(213, pathId.size()) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) + } + @Test fun `truncate long description or payerNote`() { val longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." 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 2068d3af4..aa026b8bf 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 @@ -226,6 +228,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { val tlvs = tlvsWithoutSignature + Signature(signature) val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gpzwyzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgzhxcvjdh925x0jyyxzrdc5s2mwqtmpf4zezd7mg094lmcwqh3xyw2n6jdzkl80jj2euh48s00wtgad9j7wyt67rnth3dqq0fa0usrxm" + assertTrue(invoiceRequest.isValid()) assertEquals(encoded, invoiceRequest.encode()) assertEquals(invoiceRequest, InvoiceRequest.decode(encoded).get()) assertNull(invoiceRequest.offer.amount) @@ -244,11 +247,10 @@ 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), @@ -256,21 +258,99 @@ class OfferTypesTestsCommon : LightningTestSuite() { 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 = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqgn3qkppqfufxgalt7nkrherkhnepnxn65z9yn7mknwtcf4d35gjj8q5zu26duzqxgehamhuaj6ly9twcfwsdu95swqpl0z9cep4yq2j0qpqa0d3tdkk0877lg60jtmpq2yss42jayrhuekkd6rmzjn94clglr8lsk8lx9l7wu6e4sfq766scfgzvlp0fvp5v86230hwz99zuc5xywscek562j7hxjx0qzz0uae4ntplqq3qgdyhl4lcy62hzz855v8annkr46a8n9eqsn5satgpagesjqqqqqqppnqrjvugf2h366csswt7tml9ep4u7tvv4rf0wq8d4xwmjg20cfcjky6q9q7uccqs0l3etux0vcuzgpje7mye73a3k7hxysg694jj8rlmmgwzqgpwh4nf23k53cppx0ahd38nca0aujvcrkhrmv8aeax2lpkrc6ua0lqqxv3lmpuk78jqdrjlya0v8avapm7zagkvzu6aa7j787wecd20xml5zteu4erklvnlk0vtxp4y8pe4hjjh9x7syd98rehawsuwq8pfxxq0t56e5felm3s8ly68m33azdheystd29slqqgg4kgw54epcrx5gyzfxrshmgm7dlnhxkdv2yg8wp5x7etwd9uqsctrd9h8ztnrdu" + val encoded = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqgn3qkppqfufxgalt7nkrherkhnepnxn65z9yn7mknwtcf4d35gjj8q5zu26duzq2leeneh9myzzspr3wdx89vlfdtqc0w3w83j0tw8f73yvzcnzh5c5ecllf8s57unc7rud9zvy88gxd5nanxt6rjeata6dcnzarvacx9l7wu6e4sfq766scfgzvlp0fvp5v86230hwz99zuc5xywscek562j7hxjx0qzz0uae4ntplqq3qgdyhl4lcy62hzz855v8annkr46a8n9eqsn5satgpagesjqqqqqqppnqrjvugf2h366csswt7tml9ep4u7tvv4rf0wq8d4xwmjg20cfcjky6q9q7uccqs0l3etux0vcuzgpje7mye73a3k7hxysg694jj8rlmmgwzqgpwh4nf23k53cppx0ahd38nca0aujvcrkhrmv8aeax2lpkrc6ua0lqqxv3lmpuk78jqdrjlya0v8avapm7zagkvzu6aa7j787wecd20xml5zteu4erklvnlk0vtxp4y8pe4hjjh9x7syd98rehawsuwq8pfxxq0t56e5felm3s8ly68m33azdheystd29slqqgg4kgw54epcrx5gyzfxrshmgm7v" + assertTrue(invoiceRequest.isValid()) 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