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..31a4c39bf --- /dev/null +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt @@ -0,0 +1,112 @@ +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.* + +/** + * 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()) + } + } + } +} + +/** + * 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. + * + * 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..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 @@ -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,109 @@ 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: UnverifiedContactAddress?, + ) : 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.address.toString().encodeToByteArray(), out) + LightningCodecs.writeBytes(it.expectedOfferSigningKey.value, 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 = 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) + } + + 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 +307,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 +350,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..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,6 +16,8 @@ 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.payment.ContactAddress +import fr.acinq.lightning.payment.UnverifiedContactAddress import fr.acinq.lightning.utils.toByteVector /** @@ -415,6 +417,79 @@ 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)) + } + } + } + + /** + * 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. */ @@ -889,6 +964,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: UnverifiedContactAddress? = records.get()?.let { pa -> records.get()?.let { ps -> UnverifiedContactAddress(pa.address, ps.offerSigningKey) } } private val signature: ByteVector64 = records.get()!!.signature fun isValid(): Boolean = @@ -897,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 { @@ -959,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)) } @@ -989,6 +1079,10 @@ 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, + InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader, Signature.tag to Signature as TlvValueReader, ) ) @@ -1028,6 +1122,10 @@ 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, + 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 new file mode 100644 index 000000000..e07427836 --- /dev/null +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt @@ -0,0 +1,66 @@ +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 +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +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) + 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. + 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) + val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("bob@acinq.co")!!, issuerKey.publicKey()) + assertTrue(payerAddress.verify(bobOffer)) + } + } + +} \ 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..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 @@ -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,91 @@ 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 offer`() { + 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 `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." @@ -176,24 +268,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..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 @@ -11,6 +11,8 @@ 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.payment.UnverifiedContactAddress import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.testLoggerFactory @@ -20,8 +22,12 @@ 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.InvoiceRequestPayerAddressSignature 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 +228,8 @@ 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) assertNull(invoiceRequest.offer.description) @@ -238,6 +246,113 @@ class OfferTypesTestsCommon : LightningTestSuite() { } } + @Test + fun `invoice request with contact offer`() { + val payerKey = PrivateKey.fromHex("80803163f4c8422f492ca6a03f5a6ed116a313ebcf9b2c794249a30221e87313") + val contactSecret = ByteVector32.fromValidHex("f6b50c250267c2f4b03461f4a8beee114a2e628623a18cda9a54bd7348cf0084") + val payerOffer = Offer.decode("lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqzs0wvvqg8lcu47r8kvwpyqevldjvlg7cm0tnzgydz6efr3laa58pqyqht6e54gm2guqsn87mkcneuwh77fxvpmt3akr7u7n90smpudwwhlsqrxglas7t0reqx3e0jwhkr7kwsalpw5txpwdw7lf0rl8vux48ndl6p9u72u3m0kflm8k9nq6jrsu6meftjn0gzxjn3um7hgw8qrs5nrq846dv6yulaccrljdracc73xmujg9k4zc0sqyy2my822usupn2yzpynpcta5dlx").get() + val tlvsWithoutSignature = setOf( + InvoiceRequestMetadata(ByteVector.fromHex("a37561651a82fbd68b9c243595f45a9bbb6a906808608497842deb0e24588d61")), + OfferIssuerId(nodeId), + InvoiceRequestAmount(10_000.msat), + InvoiceRequestPayerId(payerKey.publicKey()), + InvoiceRequestContactSecret(contactSecret), + InvoiceRequestPayerOffer(payerOffer), + ) + val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream(tlvsWithoutSignature)), payerKey) + val tlvs = tlvsWithoutSignature + Signature(signature) + val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) + val encoded = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqgn3qkppqfufxgalt7nkrherkhnepnxn65z9yn7mknwtcf4d35gjj8q5zu26duzq2leeneh9myzzspr3wdx89vlfdtqc0w3w83j0tw8f73yvzcnzh5c5ecllf8s57unc7rud9zvy88gxd5nanxt6rjeata6dcnzarvacx9l7wu6e4sfq766scfgzvlp0fvp5v86230hwz99zuc5xywscek562j7hxjx0qzz0uae4ntplqq3qgdyhl4lcy62hzz855v8annkr46a8n9eqsn5satgpagesjqqqqqqppnqrjvugf2h366csswt7tml9ep4u7tvv4rf0wq8d4xwmjg20cfcjky6q9q7uccqs0l3etux0vcuzgpje7mye73a3k7hxysg694jj8rlmmgwzqgpwh4nf23k53cppx0ahd38nca0aujvcrkhrmv8aeax2lpkrc6ua0lqqxv3lmpuk78jqdrjlya0v8avapm7zagkvzu6aa7j787wecd20xml5zteu4erklvnlk0vtxp4y8pe4hjjh9x7syd98rehawsuwq8pfxxq0t56e5felm3s8ly68m33azdheystd29slqqgg4kgw54epcrx5gyzfxrshmgm7v" + assertTrue(invoiceRequest.isValid()) + 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(payerOffer, invoiceRequest.payerOffer) + } + + @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 fun `compute merkle tree root`() { data class TestCase(val tlvs: String, val count: Int, val expected: ByteVector32)