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..66550ec3d --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt @@ -0,0 +1,89 @@ +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 { + val ecdh = theirOffer.contactNodeIds.first().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 8bc70d23f..ea367a980 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/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)