From 6d55e4ab95811c5c9117e8c83a89a822f726625a Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 17 Oct 2024 09:39:54 +0200 Subject: [PATCH] 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/NodeParams.kt | 2 +- .../kotlin/fr/acinq/lightning/io/Peer.kt | 11 +- .../fr/acinq/lightning/payment/Contacts.kt | 91 +++++++++++++++ .../payment/IncomingPaymentHandler.kt | 2 +- .../acinq/lightning/payment/OfferManager.kt | 32 +++++- .../lightning/payment/OfferPaymentMetadata.kt | 103 ++++++++++++++++- .../fr/acinq/lightning/wire/OfferTypes.kt | 74 ++++++++++++- .../lightning/payment/ContactsTestsCommon.kt | 60 ++++++++++ .../payment/OfferManagerTestsCommon.kt | 71 +++++++++--- .../OfferPaymentMetadataTestsCommon.kt | 104 ++++++++++++++++-- .../payment/PaymentPacketTestsCommon.kt | 2 +- 11 files changed, 512 insertions(+), 40 deletions(-) create mode 100644 src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt create mode 100644 src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 94f519965..89aee3bf7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -261,7 +261,7 @@ data class NodeParams( * This offer will stay valid after restoring the seed on a different device. * @return the default offer and the private key that will sign invoices for this offer. */ - fun defaultOffer(trampolineNodeId: PublicKey): Pair { + fun defaultOffer(trampolineNodeId: PublicKey): OfferTypes.OfferAndKey { // We generate a deterministic blindingSecret based on: // - a custom tag indicating that this is used in the Bolt 12 context // - our trampoline node, which is used as an introduction node for the offer's blinded path diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 9d5aaca5f..c5b161cd2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -129,7 +129,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() @@ -705,7 +705,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 { @@ -715,7 +718,7 @@ class Peer( .first() ) } - send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeout)) + send(PayOffer(paymentId, payerKey, payerNote, amount, offer, contactSecret, fetchInvoiceTimeout)) return res.await() } @@ -766,7 +769,7 @@ class Peer( .first() .let { event -> replyTo.complete(event.address) } } - peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).first, languageSubtag)) + peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, languageSubtag)) return replyTo.await() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt new file mode 100644 index 000000000..dc4366f9f --- /dev/null +++ b/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/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index a440c2c18..128837ba1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -499,7 +499,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { } is PaymentOnion.FinalPayload.Blinded -> { // We encrypted the payment metadata for ourselves in the blinded path we included in the invoice. - return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodeId, finalPayload.pathId)) { + return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodePrivateKey, finalPayload.pathId, paymentPart.paymentHash)) { null -> { logger.warning { "invalid path_id: ${finalPayload.pathId.toHex()}" } Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight)) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt index 88bf50421..7578a346c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt @@ -47,7 +47,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v private val localOffers: HashMap = HashMap() init { - registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).first, null) + registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, null) } fun registerOffer(offer: OfferTypes.Offer, pathId: ByteVector32?) { @@ -58,7 +58,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 { localOffers[ByteVector32.Zeroes] }?.let { OfferTypes.InvoiceRequestPayerOffer(it) }, + ) + 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. @@ -162,7 +168,27 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v it.take(63) + "…" } } - val pathId = OfferPaymentMetadata.V1(ByteVector32(decrypted.pathId), amount, preimage, request.payerId, truncatedPayerNote, request.quantity, currentTimestampMillis()).toPathId(nodeParams.nodePrivateKey) + // 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 pathId = OfferPaymentMetadata.V2( + ByteVector32(decrypted.pathId), + amount, + preimage, + request.payerId, + truncatedPayerNote, + request.quantity, + request.contactSecret, + payerOffer, + request.payerAddress, + currentTimestampMillis() + ).toPathId(nodeParams.nodePrivateKey) val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId))).write().toByteVector() val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta val paymentInfo = OfferTypes.PaymentInfo( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index cdf124b3d..7bf2ce46e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt @@ -6,8 +6,10 @@ import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output 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 /** * The flow for Bolt 12 offer payments is the following: @@ -37,6 +39,7 @@ sealed class OfferPaymentMetadata { LightningCodecs.writeByte(this.version.toInt(), out) when (this) { is V1 -> this.write(out) + is V2 -> this.write(out) } return out.toByteArray().byteVector() } @@ -48,6 +51,25 @@ sealed class OfferPaymentMetadata { val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey) encoded + signature } + 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 priv = V2.deriveKey(nodeKey, paymentHash) + val nonce = paymentHash.take(12).toByteArray() + ChaCha20Poly1305.encrypt(priv.value.toByteArray(), nonce, encoded, paymentHash.toByteArray()) + } + val out = ByteArrayOutput() + out.write(2) // version + out.write(encrypted) + out.write(mac) + out.toByteArray().byteVector() + } } /** In this first version, we simply sign the payment metadata to verify its authenticity when receiving the payment. */ @@ -86,6 +108,69 @@ sealed class OfferPaymentMetadata { } } + /** In this version, we encrypt the payment metadata with a key derived from our seed. */ + data class V2( + override val offerId: ByteVector32, + override val amount: MilliSatoshi, + override val preimage: ByteVector32, + val payerKey: PublicKey, + val payerNote: String?, + val quantity: Long, + val contactSecret: ByteVector32?, + val payerOffer: OfferTypes.Offer?, + val payerAddress: ContactAddress?, + override val createdAtMillis: Long + ) : OfferPaymentMetadata() { + override val version: Byte get() = 2 + + private fun writeOptionalBytes(data: ByteArray?, out: Output) = when (data) { + null -> LightningCodecs.writeU16(0, out) + else -> { + LightningCodecs.writeU16(data.size, out) + LightningCodecs.writeBytes(data, out) + } + } + + fun write(out: Output) { + LightningCodecs.writeBytes(offerId, out) + LightningCodecs.writeU64(amount.toLong(), out) + LightningCodecs.writeBytes(preimage, out) + LightningCodecs.writeBytes(payerKey.value, out) + writeOptionalBytes(payerNote?.encodeToByteArray(), out) + LightningCodecs.writeU64(quantity, out) + writeOptionalBytes(contactSecret?.toByteArray(), out) + writeOptionalBytes(payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records) }, out) + writeOptionalBytes(payerAddress?.toString()?.encodeToByteArray(), out) + LightningCodecs.writeU64(createdAtMillis, out) + } + + companion object { + private fun readOptionalBytes(input: Input): ByteArray? = when (val size = LightningCodecs.u16(input)) { + 0 -> null + else -> LightningCodecs.bytes(input, size) + } + + fun read(input: Input): V2 { + val offerId = LightningCodecs.bytes(input, 32).byteVector32() + val amount = LightningCodecs.u64(input).msat + val preimage = LightningCodecs.bytes(input, 32).byteVector32() + val payerKey = PublicKey(LightningCodecs.bytes(input, 33)) + val payerNote = readOptionalBytes(input)?.decodeToString() + val quantity = LightningCodecs.u64(input) + val contactSecret = readOptionalBytes(input)?.byteVector32() + val payerOffer = readOptionalBytes(input)?.let { OfferTypes.Offer.tlvSerializer.read(it) }?.let { OfferTypes.Offer(it) } + val payerAddress = readOptionalBytes(input)?.decodeToString()?.let { ContactAddress.fromString(it) } + val createdAtMillis = LightningCodecs.u64(input) + return V2(offerId, amount, preimage, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress, createdAtMillis) + } + + fun deriveKey(nodeKey: PrivateKey, paymentHash: ByteVector32): PrivateKey { + val tweak = Crypto.sha256("offer_payment_metadata_v2".encodeToByteArray() + paymentHash.toByteArray() + nodeKey.value.toByteArray()) + return nodeKey * PrivateKey(tweak) + } + } + } + companion object { /** * Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB). @@ -95,6 +180,7 @@ sealed class OfferPaymentMetadata { val input = ByteArrayInput(encoded.toByteArray()) return when (val version = LightningCodecs.byte(input)) { 1 -> V1.read(input) + 2 -> V2.read(input) else -> throw IllegalArgumentException("unknown offer payment metadata version: $version") } } @@ -103,7 +189,7 @@ sealed class OfferPaymentMetadata { * 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. */ - fun fromPathId(nodeId: PublicKey, pathId: ByteVector): OfferPaymentMetadata? { + fun fromPathId(nodeKey: PrivateKey, pathId: ByteVector, paymentHash: ByteVector32): OfferPaymentMetadata? { if (pathId.isEmpty()) return null val input = ByteArrayInput(pathId.toByteArray()) when (LightningCodecs.byte(input)) { @@ -113,10 +199,23 @@ sealed class OfferPaymentMetadata { val metadata = LightningCodecs.bytes(input, metadataSize) val signature = LightningCodecs.bytes(input, 64).byteVector64() // Note that the signature includes the version byte. - if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeId)) return null + if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeKey.publicKey())) return null // This call is safe since we verified that we have the right number of bytes and the signature was valid. return V1.read(ByteArrayInput(metadata)) } + 2 -> { + val priv = V2.deriveKey(nodeKey, paymentHash) + val nonce = paymentHash.take(12).toByteArray() + val encryptedSize = input.availableBytes - 16 + return try { + val encrypted = LightningCodecs.bytes(input, encryptedSize) + val mac = LightningCodecs.bytes(input, 16) + val decrypted = ChaCha20Poly1305.decrypt(priv.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac) + V2.read(ByteArrayInput(decrypted)) + } catch (_: Throwable) { + null + } + } else -> return null } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt index 55d8704a7..254e9880b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt @@ -13,6 +13,7 @@ import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.message.OnionMessages +import fr.acinq.lightning.payment.ContactAddress /** * Lightning Bolt 12 offers @@ -408,6 +409,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. */ @@ -717,6 +769,9 @@ object OfferTypes { } } + /** A bolt 12 offer and the private key used to sign invoices for that offer. */ + data class OfferAndKey(val offer: Offer, val privateKey: PrivateKey) + data class Offer(val records: TlvStream) { val chains: List = records.get()?.chains ?: listOf(Block.LivenetGenesisBlock.hash) val metadata: ByteVector? = records.get()?.data @@ -797,9 +852,13 @@ object OfferTypes { blindingSecret: PrivateKey, additionalTlvs: Set = setOf(), customTlvs: Set = setOf() - ): Pair { + ): OfferAndKey { if (description == null) require(amount == null) { "an offer description must be provided if the amount isn't null" } - val blindedRouteDetails = OnionMessages.buildRouteToRecipient(blindingSecret, listOf(OnionMessages.IntermediateNode(EncodedNodeId.WithPublicKey.Plain(trampolineNodeId))), OnionMessages.Destination.Recipient(EncodedNodeId.WithPublicKey.Wallet(nodeParams.nodeId), null)) + val blindedRouteDetails = OnionMessages.buildRouteToRecipient( + blindingSecret, + listOf(OnionMessages.IntermediateNode(EncodedNodeId.WithPublicKey.Plain(trampolineNodeId))), + OnionMessages.Destination.Recipient(EncodedNodeId.WithPublicKey.Wallet(nodeParams.nodeId), null) + ) val tlvs: Set = setOfNotNull( if (nodeParams.chainHash != Block.LivenetGenesisBlock.hash) OfferChains(listOf(nodeParams.chainHash)) else null, amount?.let { OfferAmount(it) }, @@ -808,7 +867,7 @@ object OfferTypes { // Note that we don't include an offer_node_id since we're using a blinded path. OfferPaths(listOf(ContactInfo.BlindedPath(blindedRouteDetails.route))), ) - return Pair(Offer(TlvStream(tlvs + additionalTlvs, customTlvs)), blindedRouteDetails.blindedPrivateKey(nodeParams.nodePrivateKey)) + return OfferAndKey(Offer(TlvStream(tlvs + additionalTlvs, customTlvs)), blindedRouteDetails.blindedPrivateKey(nodeParams.nodePrivateKey)) } fun validate(records: TlvStream): Either { @@ -858,6 +917,9 @@ object OfferTypes { val quantity: Long = quantity_opt ?: 1 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 = @@ -966,6 +1028,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, ) ) @@ -1005,6 +1070,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/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt new file mode 100644 index 000000000..da4f8f9aa --- /dev/null +++ b/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/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index 8d1c55f0e..9984ab031 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -4,6 +4,7 @@ import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* +import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.DecryptedPacket @@ -18,10 +19,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.* @@ -63,13 +61,13 @@ class OfferManagerTestsCommon : LightningTestSuite() { return offer } - private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V1 { + private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V2 { val blindedRoute = invoice.blindedPaths.first().route.route assertEquals(2, blindedRoute.encryptedPayloads.size) val (_, nextBlinding) = RouteBlinding.decryptPayload(trampolineKey, blindedRoute.blindingKey, 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.nodeId, pathId) as OfferPaymentMetadata.V1 + return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodePrivateKey, pathId, invoice.paymentHash) as OfferPaymentMetadata.V2 } @Test @@ -81,7 +79,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) assertTrue(invoiceRequests.size == 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -115,7 +113,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager) // 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) assertTrue(invoiceRequests.size == 1) val (messageForAliceTrampoline, nextNodeAliceTrampoline) = trampolineRelay(invoiceRequests.first(), bobTrampolineKey) @@ -148,7 +146,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager) // 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. @@ -170,7 +168,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager) // 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. @@ -195,7 +193,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager) // Bob sends an invalid 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) assertNull(aliceOfferManager.receiveMessage(messageForAlice.copy(blindingKey = randomKey().publicKey()), listOf(), 0)) @@ -209,7 +207,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager) // 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. @@ -227,7 +225,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager, 50_000.msat) // 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. @@ -248,7 +246,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager, null) // 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. @@ -270,7 +268,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) assertTrue(invoiceRequests.size == 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -299,7 +297,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) assertTrue(invoiceRequests.size == 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -316,8 +314,47 @@ class OfferManagerTestsCommon : LightningTestSuite() { // The payer note is truncated in the payment metadata. val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey) - assertEquals(64, metadata.payerNote!!.length) + assertEquals(64, metadata.payerNote?.length) assertEquals(payerNote.take(63), metadata.payerNote!!.take(63)) } + @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 = createOffer(aliceOfferManager, amount = 1000.msat) + + // 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) + assertTrue(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(64, 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/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt index 4ba59501b..23557e45c 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt @@ -1,14 +1,24 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.byteVector32 +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 +import kotlin.test.assertTrue class OfferPaymentMetadataTestsCommon { + @Test fun `encode - decode v1 metadata`() { val nodeKey = randomKey() @@ -23,7 +33,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, Crypto.sha256(metadata.preimage).byteVector32())) } @Test @@ -40,30 +50,108 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, Crypto.sha256(metadata.preimage).byteVector32())) + } + + @Test + fun `encode - decode v2 metadata`() { + val nodeKey = randomKey() + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).byteVector32() + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 200_000_000.msat, + preimage = preimage, + payerKey = randomKey().publicKey(), + payerNote = "thanks for all the fish", + quantity = 1, + contactSecret = null, + payerOffer = null, + payerAddress = null, + createdAtMillis = 0 + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertTrue(pathId.size() in 150..200) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash)) + } + + @Test + fun `encode - decode v2 metadata with contact information`() { + val nodeKey = randomKey() + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).byteVector32() + val payerOffer = OfferTypes.Offer.createBlindedOffer( + amount = null, + description = null, + nodeParams = TestConstants.Alice.nodeParams, + trampolineNodeId = randomKey().publicKey(), + features = Features( + Feature.VariableLengthOnion to FeatureSupport.Optional, + Feature.PaymentSecret to FeatureSupport.Optional, + Feature.BasicMultiPartPayment to FeatureSupport.Optional, + ), + blindingSecret = randomKey() + ).offer + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 200_000_000.msat, + preimage = preimage, + payerKey = randomKey().publicKey(), + payerNote = "hello there", + quantity = 1, + contactSecret = randomBytes32(), + payerOffer = payerOffer, + payerAddress = null, + createdAtMillis = 0 + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertTrue(pathId.size() in 400..450) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash)) } @Test fun `decode invalid path_id`() { val nodeKey = randomKey() - val metadata = OfferPaymentMetadata.V1( + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).byteVector32() + 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 metadataV2 = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = preimage, + payerKey = randomKey().publicKey(), + payerNote = null, + quantity = 1, + contactSecret = randomBytes32(), + payerOffer = null, + payerAddress = null, + createdAtMillis = 0 + ) 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 + metadataV2.toPathId(nodeKey).drop(1), // missing bytes at the start + metadataV2.toPathId(nodeKey).dropRight(1), // missing bytes at the end + metadataV2.toPathId(nodeKey).let { it.update(13, it[13].xor(0xff.toByte())) }, // modified bytes + metadataV2.toPathId(nodeKey).concat(ByteVector("deadbeef")), // additional bytes + metadataV2.toPathId(randomKey()), // encrypted with different key ) testCases.forEach { - assertNull(OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), it)) + assertNull(OfferPaymentMetadata.fromPathId(nodeKey, it, paymentHash)) } } + } \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt index 9b699dee3..da8d6b445 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt @@ -459,7 +459,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { // E can correctly decrypt the blinded payment. val payloadE = IncomingPaymentPacket.decrypt(addE, privE).right!! assertIs(payloadE) - val paymentMetadata = OfferPaymentMetadata.fromPathId(e, payloadE.pathId) + val paymentMetadata = OfferPaymentMetadata.fromPathId(privE, payloadE.pathId, addE.paymentHash) assertNotNull(paymentMetadata) assertEquals(offer.offerId, paymentMetadata.offerId) assertEquals(paymentMetadata.paymentHash, invoice.paymentHash)