diff --git a/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt b/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt index 03153c922..938304873 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt @@ -53,6 +53,15 @@ object RouteBlinding { val encryptedPayloads: List = blindedNodes.map { it.encryptedPayload } } + /** + * @param route blinded route. + * @param lastBlinding blinding point for the last node, which can be used to derive the blinded private key. + */ + data class BlindedRouteDetails(val route: BlindedRoute, val lastBlinding: PublicKey) { + /** @param nodeKey private key associated with our non-blinded node_id. */ + fun blindedPrivateKey(nodeKey: PrivateKey): PrivateKey = derivePrivateKey(nodeKey, lastBlinding) + } + /** * Blind the provided route and encrypt intermediate nodes' payloads. * @@ -61,7 +70,7 @@ object RouteBlinding { * @param payloads payloads that should be encrypted for each node on the route. * @return a blinded route. */ - fun create(sessionKey: PrivateKey, publicKeys: List, payloads: List): BlindedRoute { + fun create(sessionKey: PrivateKey, publicKeys: List, payloads: List): BlindedRouteDetails { require(publicKeys.size == payloads.size) { "a payload must be provided for each node in the blinded path" } var e = sessionKey val (blindedHops, blindingKeys) = publicKeys.zip(payloads).map { pair -> @@ -79,7 +88,7 @@ object RouteBlinding { e *= PrivateKey(Crypto.sha256(blindingKey.value.toByteArray() + sharedSecret.toByteArray())) Pair(BlindedNode(blindedPublicKey, ByteVector(encryptedPayload + mac)), blindingKey) }.unzip() - return BlindedRoute(EncodedNodeId(publicKeys.first()), blindingKeys.first(), blindedHops) + return BlindedRouteDetails(BlindedRoute(EncodedNodeId(publicKeys.first()), blindingKeys.first(), blindedHops), blindingKeys.last()) } /** diff --git a/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt index f6b5526e8..4ecffda90 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt @@ -70,7 +70,7 @@ object OnionMessages { blindingSecret, intermediateNodes.map { it.nodeId } + destination.nodeId, intermediatePayloads + lastPayload - ) + ).route } is Destination.BlindedPath -> when { intermediateNodes.isEmpty() -> destination.route @@ -85,7 +85,7 @@ object OnionMessages { blindingSecret, intermediateNodes.map { it.nodeId }, intermediatePayloads - ) + ).route RouteBlinding.BlindedRoute( routePrefix.introductionNodeId, routePrefix.blindingKey, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt index a493cabc3..cc37f3ebd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt @@ -343,15 +343,14 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle NodeHop(walletParams.trampolineNode.id, request.recipient, fees.cltvExpiryDelta, fees.calculateFees(request.amount)) ) } - when (request.paymentRequest) { is Bolt11Invoice -> { val minFinalExpiryDelta = request.paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA val finalExpiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta) val finalPayload = PaymentOnion.FinalPayload.Standard.createSinglePartPayload(request.amount, finalExpiry, request.paymentRequest.paymentSecret, request.paymentRequest.paymentMetadata) - val invoiceFeatures = request.paymentRequest.features val (trampolineAmount, trampolineExpiry, trampolineOnion) = if (invoiceFeatures.hasFeature(Feature.TrampolinePayment) || invoiceFeatures.hasFeature(Feature.ExperimentalTrampolinePayment)) { + // We may be paying an older version of lightning-kmp that only supports trampoline packets of size 400. OutgoingPaymentPacket.buildPacket(request.paymentHash, trampolineRoute, finalPayload, 400) } else { OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket(request.paymentRequest, trampolineRoute, finalPayload) @@ -360,8 +359,7 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle } is Bolt12Invoice -> { val finalExpiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, CltvExpiryDelta(0)) - val dummyFinalPayload = PaymentOnion.FinalPayload.Standard.createSinglePartPayload(request.amount, finalExpiry, ByteVector32.Zeroes, null) - val (trampolineAmount, trampolineExpiry, trampolineOnion) = OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket(request.paymentRequest, trampolineRoute, dummyFinalPayload) + val (trampolineAmount, trampolineExpiry, trampolineOnion) = OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket(request.paymentRequest, trampolineRoute.last(), request.amount, finalExpiry) return Triple(trampolineAmount, trampolineExpiry, trampolineOnion.packet) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt index 16766dd87..395be443a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt @@ -60,7 +60,7 @@ object OutgoingPaymentPacket { * Build an encrypted trampoline onion packet when the final recipient doesn't support trampoline. * The next-to-last trampoline node payload will contain instructions to convert to a legacy payment. * - * @param invoice an invoice (features and routing hints will be provided to the next-to-last node). + * @param invoice a Bolt11 invoice (features and routing hints will be provided to the next-to-last node). * @param hops the trampoline hops (including ourselves in the first hop, and the non-trampoline final recipient in the last hop). * @param finalPayload payload data for the final node (amount, expiry, etc) * @return a (firstAmount, firstExpiry, onion) triple where: @@ -68,7 +68,7 @@ object OutgoingPaymentPacket { * - firstExpiry is the cltv expiry for the first trampoline node in the route * - the trampoline onion to include in final payload of a normal onion */ - fun buildTrampolineToNonTrampolinePacket(invoice: PaymentRequest, hops: List, finalPayload: PaymentOnion.FinalPayload.Standard): Triple { + fun buildTrampolineToNonTrampolinePacket(invoice: Bolt11Invoice, hops: List, finalPayload: PaymentOnion.FinalPayload.Standard): Triple { // NB: the final payload will never reach the recipient, since the next-to-last trampoline hop will convert that to a legacy payment // We use the smallest final payload possible, otherwise we may overflow the trampoline onion size. val dummyFinalPayload = PaymentOnion.FinalPayload.Standard.createSinglePartPayload(finalPayload.amount, finalPayload.expiry, finalPayload.paymentSecret, null) @@ -76,19 +76,32 @@ object OutgoingPaymentPacket { val (amount, expiry, payloads) = triple val payload = when (payloads.size) { // The next-to-last trampoline hop must include invoice data to indicate the conversion to a legacy payment. - 1 -> when (invoice) { - is Bolt11Invoice -> PaymentOnion.RelayToNonTrampolinePayload.create(finalPayload.amount, finalPayload.totalAmount, finalPayload.expiry, hop.nextNodeId, invoice) - is Bolt12Invoice -> PaymentOnion.RelayToBlindedPayload.create(finalPayload.amount, finalPayload.expiry, invoice) - } + 1 -> PaymentOnion.RelayToNonTrampolinePayload.create(finalPayload.amount, finalPayload.totalAmount, finalPayload.expiry, hop.nextNodeId, invoice) else -> PaymentOnion.NodeRelayPayload.create(amount, expiry, hop.nextNodeId) } Triple(amount + hop.fee(amount), expiry + hop.cltvExpiryDelta, listOf(payload) + payloads) } val nodes = hops.map { it.nextNodeId } - val onion = buildOnion(nodes, payloads, invoice.paymentHash, 400) // TODO: remove the fixed payload length once eclair supports it + val onion = buildOnion(nodes, payloads, invoice.paymentHash, payloadLength = null) return Triple(firstAmount, firstExpiry, onion) } + /** + * Build an encrypted trampoline onion packet when the final recipient is using a blinded path. + * The trampoline payload will contain data from the invoice to allow the trampoline node to pay the blinded path. + * We only need a single trampoline node, who will find a route to the blinded path's introduction node without learning the recipient's identity. + * + * @param invoice a Bolt12 invoice (blinded path data will be provided to the trampoline node). + * @param hop the trampoline hop from the trampoline node to the recipient. + * @param finalAmount amount that should be received by the final recipient. + * @param finalExpiry cltv expiry that should be received by the final recipient. + */ + fun buildTrampolineToNonTrampolinePacket(invoice: Bolt12Invoice, hop: NodeHop, finalAmount: MilliSatoshi, finalExpiry: CltvExpiry): Triple { + val payload = PaymentOnion.RelayToBlindedPayload.create(finalAmount, finalExpiry, invoice) + val onion = buildOnion(listOf(hop.nodeId), listOf(payload), invoice.paymentHash, payloadLength = null) + return Triple(finalAmount + hop.fee(finalAmount), finalExpiry + hop.cltvExpiryDelta, onion) + } + /** * Build an encrypted onion packet with the given final payload. * diff --git a/src/commonTest/kotlin/fr/acinq/lightning/crypto/sphinx/SphinxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/crypto/sphinx/SphinxTestsCommon.kt index 6e5f67b79..188196f57 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/crypto/sphinx/SphinxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/crypto/sphinx/SphinxTestsCommon.kt @@ -4,8 +4,8 @@ import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey -import fr.acinq.lightning.EncodedNodeId import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.EncodedNodeId import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.Sphinx.computeEphemeralPublicKeysAndSharedSecrets import fr.acinq.lightning.crypto.sphinx.Sphinx.decodePayloadLength @@ -425,7 +425,7 @@ class SphinxTestsCommon : LightningTestSuite() { // each node parses and forwards the packet // node #0 assertEquals(packetAndSecrets.packet.payload.size(), packetLength) - val decrypted0 = Sphinx.peel(privKeys[0], associatedData, packetAndSecrets.packet, ).right!! + val decrypted0 = Sphinx.peel(privKeys[0], associatedData, packetAndSecrets.packet).right!! // node #1 assertEquals(decrypted0.nextPacket.payload.size(), packetLength) val decrypted1 = Sphinx.peel(privKeys[1], associatedData, decrypted0.nextPacket).right!! @@ -584,32 +584,39 @@ class SphinxTestsCommon : LightningTestSuite() { @Test fun `create blinded route -- reference test vector`() { val sessionKey = PrivateKey(ByteVector32("0101010101010101010101010101010101010101010101010101010101010101")) - val blindedRoute = RouteBlinding.create(sessionKey, publicKeys, routeBlindingPayloads) + val (blindedRoute, lastBlinding) = RouteBlinding.create(sessionKey, publicKeys, routeBlindingPayloads) assertEquals(blindedRoute.introductionNode.nodeId, EncodedNodeId(publicKeys[0])) assertEquals(blindedRoute.introductionNodeId, EncodedNodeId(publicKeys[0])) assertEquals(blindedRoute.introductionNode.blindedPublicKey, PublicKey.fromHex("02ec68ed555f5d18b12fe0e2208563c3566032967cf11dc29b20c345449f9a50a2")) assertEquals(blindedRoute.introductionNode.blindingEphemeralKey, PublicKey.fromHex("031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")) assertEquals(blindedRoute.introductionNode.encryptedPayload, ByteVector("af4fbf67bd52520bdfab6a88cd4e7f22ffad08d8b153b17ff303f93fdb4712")) - assertEquals(blindedRoute.blindedNodeIds, listOf( - PublicKey.fromHex("02ec68ed555f5d18b12fe0e2208563c3566032967cf11dc29b20c345449f9a50a2"), - PublicKey.fromHex("022b09d77fb3374ee3ed9d2153e15e9962944ad1690327cbb0a9acb7d90f168763"), - PublicKey.fromHex("03d9f889364dc5a173460a2a6cc565b4ca78931792115dd6ef82c0e18ced837372"), - PublicKey.fromHex("03bfddd2253b42fe12edd37f9071a3883830ed61a4bc347eeac63421629cf032b5"), - PublicKey.fromHex("03a8588bc4a0a2f0d2fb8d5c0f8d062fb4d78bfba24a85d0ddeb4fd35dd3b34110"), - )) - assertEquals(blindedRoute.subsequentNodes.map { it.blindedPublicKey }, listOf( - PublicKey.fromHex("022b09d77fb3374ee3ed9d2153e15e9962944ad1690327cbb0a9acb7d90f168763"), - PublicKey.fromHex("03d9f889364dc5a173460a2a6cc565b4ca78931792115dd6ef82c0e18ced837372"), - PublicKey.fromHex("03bfddd2253b42fe12edd37f9071a3883830ed61a4bc347eeac63421629cf032b5"), - PublicKey.fromHex("03a8588bc4a0a2f0d2fb8d5c0f8d062fb4d78bfba24a85d0ddeb4fd35dd3b34110"), - )) + assertEquals( + blindedRoute.blindedNodeIds, listOf( + PublicKey.fromHex("02ec68ed555f5d18b12fe0e2208563c3566032967cf11dc29b20c345449f9a50a2"), + PublicKey.fromHex("022b09d77fb3374ee3ed9d2153e15e9962944ad1690327cbb0a9acb7d90f168763"), + PublicKey.fromHex("03d9f889364dc5a173460a2a6cc565b4ca78931792115dd6ef82c0e18ced837372"), + PublicKey.fromHex("03bfddd2253b42fe12edd37f9071a3883830ed61a4bc347eeac63421629cf032b5"), + PublicKey.fromHex("03a8588bc4a0a2f0d2fb8d5c0f8d062fb4d78bfba24a85d0ddeb4fd35dd3b34110"), + ) + ) + assertEquals( + blindedRoute.subsequentNodes.map { it.blindedPublicKey }, listOf( + PublicKey.fromHex("022b09d77fb3374ee3ed9d2153e15e9962944ad1690327cbb0a9acb7d90f168763"), + PublicKey.fromHex("03d9f889364dc5a173460a2a6cc565b4ca78931792115dd6ef82c0e18ced837372"), + PublicKey.fromHex("03bfddd2253b42fe12edd37f9071a3883830ed61a4bc347eeac63421629cf032b5"), + PublicKey.fromHex("03a8588bc4a0a2f0d2fb8d5c0f8d062fb4d78bfba24a85d0ddeb4fd35dd3b34110"), + ) + ) assertEquals(blindedRoute.encryptedPayloads, listOf(blindedRoute.introductionNode.encryptedPayload) + blindedRoute.subsequentNodes.map { it.encryptedPayload }) - assertEquals(blindedRoute.subsequentNodes.map { it.encryptedPayload }, listOf( - ByteVector("146c9694ead7de2a54fc43e8bb927bfc377dda7ed5a2e36b327b739e368aa602e43e07e14bfb81d66e1e295f848b6f15ee6483005abb830f4ef08a9da6"), - ByteVector("8ad7d5d448f15208417a1840f82274101b3c254c24b1b49fd676fd0c4293c9aa66ed51da52579e934a869f016f213044d1b13b63bf586e9c9832106b59"), - ByteVector("52a45a884542d180e76fe84fc13e71a01f65d943ff89aed29b94644a91b037b9143cfda8f1ff25ba61c37108a5ae57d9ddc5ab688ee8b2f9f6bd94522c"), - ByteVector("6a4ac764cbf146ffd73299563b07c56052af4acd681d9d0882728c6f399ace90392b694d5e347612dc1417f1b3a9c82d6d4db18b6eb32134e554db7d00"), - )) + assertEquals( + blindedRoute.subsequentNodes.map { it.encryptedPayload }, listOf( + ByteVector("146c9694ead7de2a54fc43e8bb927bfc377dda7ed5a2e36b327b739e368aa602e43e07e14bfb81d66e1e295f848b6f15ee6483005abb830f4ef08a9da6"), + ByteVector("8ad7d5d448f15208417a1840f82274101b3c254c24b1b49fd676fd0c4293c9aa66ed51da52579e934a869f016f213044d1b13b63bf586e9c9832106b59"), + ByteVector("52a45a884542d180e76fe84fc13e71a01f65d943ff89aed29b94644a91b037b9143cfda8f1ff25ba61c37108a5ae57d9ddc5ab688ee8b2f9f6bd94522c"), + ByteVector("6a4ac764cbf146ffd73299563b07c56052af4acd681d9d0882728c6f399ace90392b694d5e347612dc1417f1b3a9c82d6d4db18b6eb32134e554db7d00"), + ) + ) + assertEquals(blindedRoute.blindedNodeIds.last(), RouteBlinding.derivePrivateKey(privKeys.last(), lastBlinding).publicKey()) // The introduction point can decrypt its encrypted payload and obtain the next ephemeral public key. val (payload0, ephKey1) = RouteBlinding.decryptPayload(privKeys[0], blindedRoute.introductionNode.blindingEphemeralKey, blindedRoute.encryptedPayloads[0]).right!! @@ -650,7 +657,7 @@ class SphinxTestsCommon : LightningTestSuite() { ByteVector("042102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145"), ByteVector("010f000000000000000000000000000000 061000112233445566778899aabbccddeeff") ) - val blindedRoute = RouteBlinding.create(sessionKey, publicKeys.drop(2), payloads) + val blindedRoute = RouteBlinding.create(sessionKey, publicKeys.drop(2), payloads).route assertEquals(blindedRoute.blindingKey, PublicKey.fromHex("031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")) Triple(blindedRoute.blindingKey, blindedRoute, payloads) } @@ -662,24 +669,28 @@ class SphinxTestsCommon : LightningTestSuite() { // NB: this payload contains the blinding key override. ByteVector("0421027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007 0821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f") ) - Pair(RouteBlinding.create(sessionKey, publicKeys.take(2), payloads), payloads) + Pair(RouteBlinding.create(sessionKey, publicKeys.take(2), payloads).route, payloads) } val blindedRoute = RouteBlinding.BlindedRoute(EncodedNodeId(publicKeys[0]), blindedRouteStart.blindingKey, blindedRouteStart.blindedNodes + blindedRouteEnd.blindedNodes) assertEquals(blindedRoute.blindingKey, PublicKey.fromHex("024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766")) - assertEquals(blindedRoute.blindedNodeIds, listOf( - PublicKey.fromHex("0303176d13958a8a59d59517a6223e12cf291ba5f65c8011efcdca0a52c3850abc"), - PublicKey.fromHex("03adbdd3c0fb69641e96de2d5ac923ffc0910d3ed4dfe2314609fae61a71df4da2"), - PublicKey.fromHex("021026e6369e42b7f6d723c0c56a3e0b4d67111f07685bd03e9fa6d93ac6bb6dbe"), - PublicKey.fromHex("02ba3db3fe7f1ed28c4d82f28cf358373cbf3241a16aba265b1b6fb26f094c0c7f"), - PublicKey.fromHex("0379d4ca14cb19e2f7bcb217d36267e3d03b027bc4228923967f5b2e32cbb763c1"), - )) - assertEquals(blindedRoute.encryptedPayloads, listOf( - ByteVector("31da0d438752ed0f19ccd970a386ead7155fd187becd4e1770d561dffdb03d3568dac746dde98725f146582cb040207e8b6c070e28d707564a4dd9fb53f9274ad69d09add393b509a2fa42df5055d7c8aeda5881d5aa"), - ByteVector("d9dfa92f898dc8e37b73c944aa4205f225337b2edde67623e775c79e2bcf395dc205004aa07fdc65712afa5c2687aff9bb3d5e6af7c89cc94f23f962a27844ce7629773f9413ebcf131dbc35818410df207f29b013b0"), - ByteVector("30015dcdcbce70bdcd0125be8ccd541b101d95bcb049ccfc737f91c98cc139cb6f16354ec5a38e77eca769c2245ac4467524d6"), - ByteVector("11e49a0e5f4f8a73b30551bd20448abeb297339b6983ab30d4a227a858311656cbf2444aeff66bd4c8f320ce00ce4ddfed7ca3"), - ByteVector("fe7e62b65ac8e1c2a319ba53a5519b3f8073416971ae3e722ebc008f38999d590d70d40557e44557c0d32b891bd967119c1f78"), - )) + assertEquals( + blindedRoute.blindedNodeIds, listOf( + PublicKey.fromHex("0303176d13958a8a59d59517a6223e12cf291ba5f65c8011efcdca0a52c3850abc"), + PublicKey.fromHex("03adbdd3c0fb69641e96de2d5ac923ffc0910d3ed4dfe2314609fae61a71df4da2"), + PublicKey.fromHex("021026e6369e42b7f6d723c0c56a3e0b4d67111f07685bd03e9fa6d93ac6bb6dbe"), + PublicKey.fromHex("02ba3db3fe7f1ed28c4d82f28cf358373cbf3241a16aba265b1b6fb26f094c0c7f"), + PublicKey.fromHex("0379d4ca14cb19e2f7bcb217d36267e3d03b027bc4228923967f5b2e32cbb763c1"), + ) + ) + assertEquals( + blindedRoute.encryptedPayloads, listOf( + ByteVector("31da0d438752ed0f19ccd970a386ead7155fd187becd4e1770d561dffdb03d3568dac746dde98725f146582cb040207e8b6c070e28d707564a4dd9fb53f9274ad69d09add393b509a2fa42df5055d7c8aeda5881d5aa"), + ByteVector("d9dfa92f898dc8e37b73c944aa4205f225337b2edde67623e775c79e2bcf395dc205004aa07fdc65712afa5c2687aff9bb3d5e6af7c89cc94f23f962a27844ce7629773f9413ebcf131dbc35818410df207f29b013b0"), + ByteVector("30015dcdcbce70bdcd0125be8ccd541b101d95bcb049ccfc737f91c98cc139cb6f16354ec5a38e77eca769c2245ac4467524d6"), + ByteVector("11e49a0e5f4f8a73b30551bd20448abeb297339b6983ab30d4a227a858311656cbf2444aeff66bd4c8f320ce00ce4ddfed7ca3"), + ByteVector("fe7e62b65ac8e1c2a319ba53a5519b3f8073416971ae3e722ebc008f38999d590d70d40557e44557c0d32b891bd967119c1f78"), + ) + ) // The introduction point can decrypt its encrypted payload and obtain the next ephemeral public key. val (payload0, ephKey1) = RouteBlinding.decryptPayload(privKeys[0], blindedRoute.blindingKey, blindedRoute.encryptedPayloads[0]).right!! @@ -716,7 +727,7 @@ class SphinxTestsCommon : LightningTestSuite() { @Test fun `invalid blinded route`() { - val encryptedPayloads = RouteBlinding.create(sessionKey, publicKeys, routeBlindingPayloads).encryptedPayloads + val encryptedPayloads = RouteBlinding.create(sessionKey, publicKeys, routeBlindingPayloads).route.encryptedPayloads // Invalid node private key: val ephKey0 = sessionKey.publicKey() assertTrue(RouteBlinding.decryptPayload(privKeys[1], ephKey0, encryptedPayloads[0]).isLeft) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt index 86774f67f..1d79eebd1 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt @@ -92,10 +92,10 @@ class OnionMessagesTestsCommon { assertEquals("060401234567", encodedForDave.toHex()) // Building blinded path Carol -> Dave - val routeFromCarol = RouteBlinding.create(blindingOverride, listOf(carol.publicKey(), dave.publicKey()), listOf(encodedForCarol, encodedForDave)) + val routeFromCarol = RouteBlinding.create(blindingOverride, listOf(carol.publicKey(), dave.publicKey()), listOf(encodedForCarol, encodedForDave)).route // Building blinded path Alice -> Bob - val routeToCarol = RouteBlinding.create(blindingSecret, listOf(alice.publicKey(), bob.publicKey()), listOf(encodedForAlice, encodedForBob)) + val routeToCarol = RouteBlinding.create(blindingSecret, listOf(alice.publicKey(), bob.publicKey()), listOf(encodedForAlice, encodedForBob)).route val publicKeys = routeToCarol.blindedNodes.map { it.blindedPublicKey } + routeFromCarol.blindedNodes.map { it.blindedPublicKey } val encryptedPayloads = routeToCarol.encryptedPayloads + routeFromCarol.encryptedPayloads @@ -149,7 +149,7 @@ class OnionMessagesTestsCommon { val blindedPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId(bob.publicKey())))) val encodedBlindedPayload = blindedPayload.write().toByteVector() assertEquals("04210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", encodedBlindedPayload.toHex()) - val blindedRoute = RouteBlinding.create(blindingSecret, listOf(alice.publicKey()), listOf(encodedBlindedPayload)) + val blindedRoute = RouteBlinding.create(blindingSecret, listOf(alice.publicKey()), listOf(encodedBlindedPayload)).route assertEquals(blindedAlice, blindedRoute.blindedNodes.first().blindedPublicKey) assertEquals("bae3d9ea2b06efd1b7b9b49b6cdcaad0e789474a6939ffa54ff5ec9224d5b76c", Crypto.sha256(blindingKey.value + sharedSecret).toHex()) assertEquals("6970e870b473ddbc27e3098bfa45bb1aa54f1f637f803d957e6271d8ffeba89da2665d62123763d9b634e30714144a1c165ac9", blindedRoute.blindedNodes.first().encryptedPayload.toHex()) @@ -173,7 +173,7 @@ class OnionMessagesTestsCommon { val blindedPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId(carol.publicKey())), RouteBlindingEncryptedDataTlv.NextBlinding(blindingOverride))) val encodedBlindedPayload = blindedPayload.write().toByteVector() assertEquals("0421027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007082102989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f", encodedBlindedPayload.toHex()) - val blindedRoute = RouteBlinding.create(blindingSecret, listOf(bob.publicKey()), listOf(encodedBlindedPayload)) + val blindedRoute = RouteBlinding.create(blindingSecret, listOf(bob.publicKey()), listOf(encodedBlindedPayload)).route assertEquals(blindedBob, blindedRoute.blindedNodes.first().blindedPublicKey) assertEquals("9afb8b2ebc174dcf9e270be24771da7796542398d29d4ff6a4e7b6b4b9205cfe", Crypto.sha256(blindingKey.value + sharedSecret).toHex()) assertEquals("1630da85e8759b8f3b94d74a539c6f0d870a87cf03d4986175865a2985553c997b560c32613bd9184c1a6d41a37027aabdab5433009d8409a1b638eb90373778a05716af2c2140b3196dca23997cdad4cfa7a7adc8d4", blindedRoute.blindedNodes.first().encryptedPayload.toHex()) @@ -197,7 +197,7 @@ class OnionMessagesTestsCommon { val blindedPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.Padding(ByteVector.fromHex("0000000000000000000000000000000000000000000000000000000000000000000000")), RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId(dave.publicKey())))) val encodedBlindedPayload = blindedPayload.write().toByteVector() assertEquals("012300000000000000000000000000000000000000000000000000000000000000000000000421032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991", encodedBlindedPayload.toHex()) - val blindedRoute = RouteBlinding.create(blindingSecret, listOf(carol.publicKey()), listOf(encodedBlindedPayload)) + val blindedRoute = RouteBlinding.create(blindingSecret, listOf(carol.publicKey()), listOf(encodedBlindedPayload)).route assertEquals(blindedCarol, blindedRoute.blindedNodes.first().blindedPublicKey) assertEquals("cc3b918cda6b1b049bdbe469c4dd952935e7c1518dd9c7ed0cd2cd5bc2742b82", Crypto.sha256(blindingKey.value + sharedSecret).toHex()) assertEquals("8285acbceb37dfb38b877a888900539be656233cd74a55c55344fb068f9d8da365340d21db96fb41b76123207daeafdfb1f571e3fea07a22e10da35f03109a0380b3c69fcbed9c698086671809658761cf65ecbc3c07a2e5", blindedRoute.blindedNodes.first().encryptedPayload.toHex()) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt index 492464105..f100679d0 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt @@ -67,20 +67,16 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() { sessionKey: PrivateKey = randomKey(), pathId: ByteVector = randomBytes32() ): PaymentBlindedContactInfo { - val selfPayload = RouteBlindingEncryptedData(TlvStream( - RouteBlindingEncryptedDataTlv.PathId(pathId), - RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1234567), 0.msat), - RouteBlindingEncryptedDataTlv.AllowedFeatures(Features.empty) - )).write().toByteVector() - return PaymentBlindedContactInfo( - ContactInfo.BlindedPath( - RouteBlinding.create( - sessionKey, - listOf(nodeId), - listOf(selfPayload) - ) - ), PaymentInfo(1.msat, 2, CltvExpiryDelta(3), 4.msat, 5.msat, Features.empty) - ) + val selfPayload = RouteBlindingEncryptedData( + TlvStream( + RouteBlindingEncryptedDataTlv.PathId(pathId), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1234567), 0.msat), + RouteBlindingEncryptedDataTlv.AllowedFeatures(Features.empty) + ) + ).write().toByteVector() + val blindedRoute = RouteBlinding.create(sessionKey, listOf(nodeId), listOf(selfPayload)).route + val paymentInfo = PaymentInfo(1.msat, 2, CltvExpiryDelta(3), 4.msat, 5.msat, Features.empty) + return PaymentBlindedContactInfo(ContactInfo.BlindedPath(blindedRoute), paymentInfo) } @Test diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index f098e9183..a6a09bbce 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -7,7 +7,6 @@ import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.RouteBlinding -import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.crypto.sphinx.Sphinx.hash import fr.acinq.lightning.db.InMemoryPaymentsDb import fr.acinq.lightning.db.IncomingPayment @@ -1364,7 +1363,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ): Pair { val pathId = OfferPaymentMetadata.V1(offerId, totalAmount, preimage, payerKey, quantity, currentTimestampMillis()).toPathId(TestConstants.Bob.nodeParams.nodePrivateKey) val recipientData = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId))) - val route = RouteBlinding.create(randomKey(), listOf(recipientNodeId), listOf(recipientData.write().toByteVector())) + val route = RouteBlinding.create(randomKey(), listOf(recipientNodeId), listOf(recipientData.write().toByteVector())).route val payload = PaymentOnion.FinalPayload.Blinded( TlvStream( OnionPaymentPayloadTlv.AmountToForward(amount), @@ -1384,7 +1383,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { pathId: ByteVector ): Pair { val recipientData = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId))) - val route = RouteBlinding.create(randomKey(), listOf(recipientNodeId), listOf(recipientData.write().toByteVector())) + val route = RouteBlinding.create(randomKey(), listOf(recipientNodeId), listOf(recipientData.write().toByteVector())).route val payload = PaymentOnion.FinalPayload.Blinded( TlvStream( OnionPaymentPayloadTlv.AmountToForward(amount), @@ -1422,11 +1421,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return Pair(payee.db.getIncomingPayment(paymentRequest.paymentHash)!!, paymentRequest.paymentSecret) } - private fun makeReceivedWithNewChannel(payToOpen: PayToOpenRequest, feeRatio: Double = 0.1): IncomingPayment.ReceivedWith.NewChannel { - val fee = payToOpen.amountMsat * feeRatio - return IncomingPayment.ReceivedWith.NewChannel(amount = payToOpen.amountMsat - fee, serviceFee = fee, miningFee = 0.sat, channelId = randomBytes32(), txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null) - } - private suspend fun checkDbPayment(incomingPayment: IncomingPayment, db: IncomingPaymentsDb) { val dbPayment = db.getIncomingPayment(incomingPayment.paymentHash)!! assertEquals(incomingPayment.preimage, dbPayment.preimage) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt index 36d9ef08d..3bbc56a49 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt @@ -1,7 +1,6 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* -import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.nodeFee @@ -12,6 +11,7 @@ import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.channel.states.Channel import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.Sphinx +import fr.acinq.lightning.crypto.sphinx.Sphinx.hash import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.NodeHop import fr.acinq.lightning.tests.utils.LightningTestSuite @@ -22,7 +22,6 @@ import kotlin.test.* class PaymentPacketTestsCommon : LightningTestSuite() { companion object { - private val privA = randomKey() private val a = privA.publicKey() private val privB = randomKey() @@ -131,7 +130,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { fun decryptChannelRelay(add: UpdateAddHtlc, privateKey: PrivateKey): Pair { val decrypted = Sphinx.peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!! assertFalse(decrypted.isLastPacket) - val decoded = PaymentOnion.ChannelRelayPayload.read(ByteArrayInput(decrypted.payload.toByteArray())).right!! + val decoded = PaymentOnion.ChannelRelayPayload.read(decrypted.payload).right!! return Pair(decoded, decrypted.nextPacket) } @@ -139,11 +138,11 @@ class PaymentPacketTestsCommon : LightningTestSuite() { fun decryptNodeRelay(add: UpdateAddHtlc, privateKey: PrivateKey): Triple { val decrypted = Sphinx.peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!! assertTrue(decrypted.isLastPacket) - val outerPayload = PaymentOnion.FinalPayload.Standard.read(ByteArrayInput(decrypted.payload.toByteArray())).right!! + val outerPayload = PaymentOnion.FinalPayload.Standard.read(decrypted.payload).right!! val trampolineOnion = outerPayload.records.get() assertNotNull(trampolineOnion) val decryptedInner = Sphinx.peel(privateKey, add.paymentHash, trampolineOnion.packet).right!! - val innerPayload = PaymentOnion.NodeRelayPayload.read(ByteArrayInput(decryptedInner.payload.toByteArray())).right!! + val innerPayload = PaymentOnion.NodeRelayPayload.read(decryptedInner.payload).right!! assertNull(innerPayload.records.get()) assertNull(innerPayload.records.get()) assertNull(innerPayload.records.get()) @@ -155,11 +154,11 @@ class PaymentPacketTestsCommon : LightningTestSuite() { fun decryptRelayToNonTrampolinePayload(add: UpdateAddHtlc, privateKey: PrivateKey): Triple { val decrypted = Sphinx.peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!! assertTrue(decrypted.isLastPacket) - val outerPayload = PaymentOnion.FinalPayload.Standard.read(ByteArrayInput(decrypted.payload.toByteArray())).right!! + val outerPayload = PaymentOnion.FinalPayload.Standard.read(decrypted.payload).right!! val trampolineOnion = outerPayload.records.get() assertNotNull(trampolineOnion) val decryptedInner = Sphinx.peel(privateKey, add.paymentHash, trampolineOnion.packet).right!! - val innerPayload = PaymentOnion.RelayToNonTrampolinePayload.read(ByteArrayInput(decryptedInner.payload.toByteArray())).right!! + val innerPayload = PaymentOnion.RelayToNonTrampolinePayload.read(decryptedInner.payload).right!! return Triple(outerPayload, innerPayload, decryptedInner.nextPacket) } @@ -167,13 +166,33 @@ class PaymentPacketTestsCommon : LightningTestSuite() { fun decryptRelayToBlinded(add: UpdateAddHtlc, privateKey: PrivateKey): Triple { val decrypted = Sphinx.peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!! assertTrue(decrypted.isLastPacket) - val outerPayload = PaymentOnion.FinalPayload.Standard.read(ByteArrayInput(decrypted.payload.toByteArray())).right!! + val outerPayload = PaymentOnion.FinalPayload.Standard.read(decrypted.payload).right!! val trampolineOnion = outerPayload.records.get() assertNotNull(trampolineOnion) val decryptedInner = Sphinx.peel(privateKey, add.paymentHash, trampolineOnion.packet).right!! - val innerPayload = PaymentOnion.RelayToBlindedPayload.read(ByteArrayInput(decryptedInner.payload.toByteArray())).right!! + val innerPayload = PaymentOnion.RelayToBlindedPayload.read(decryptedInner.payload).right!! return Triple(outerPayload, innerPayload, decryptedInner.nextPacket) } + + // Create an HTLC paying an empty blinded path. + fun createBlindedHtlc(): Pair { + val paymentMetadata = OfferPaymentMetadata.V1(randomBytes32(), finalAmount, paymentPreimage, randomKey().publicKey(), 1, currentTimestampMillis()) + val blindedPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(paymentMetadata.toPathId(privE)))) + val blindedRoute = RouteBlinding.create(randomKey(), listOf(e), listOf(blindedPayload.write().byteVector())).route + val finalPayload = PaymentOnion.FinalPayload.Blinded( + TlvStream( + OnionPaymentPayloadTlv.AmountToForward(finalAmount), + OnionPaymentPayloadTlv.TotalAmount(finalAmount), + OnionPaymentPayloadTlv.OutgoingCltv(finalExpiry), + OnionPaymentPayloadTlv.EncryptedRecipientData(blindedRoute.encryptedPayloads.last()), + ), + blindedPayload + ) + val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) + val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(paymentMetadata.paymentHash, listOf(blindedHop), finalPayload, OnionRoutingPacket.PaymentPacketLength) + val add = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey) + return Pair(add, finalPayload) + } } @Test @@ -310,7 +329,11 @@ class PaymentPacketTestsCommon : LightningTestSuite() { Bolt11Invoice.TaggedField.RoutingInfo(routingHints) ), ByteVector.empty ) - val (amountAC, expiryAC, trampolineOnion) = OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket(invoice, trampolineHops, PaymentOnion.FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, randomBytes32(), null)) + val (amountAC, expiryAC, trampolineOnion) = OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket( + invoice, + trampolineHops, + PaymentOnion.FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, randomBytes32(), null) + ) assertEquals(amountBC, amountAC) assertEquals(expiryBC, expiryAC) @@ -365,66 +388,87 @@ class PaymentPacketTestsCommon : LightningTestSuite() { @Test fun `build a trampoline payment to blinded paths`() { - // simple trampoline route to e where e doesn't support trampoline: - // .--. - // / \ - // a -> b -> c d -> e val features = Features(Feature.BasicMultiPartPayment to FeatureSupport.Optional) val offer = OfferTypes.Offer.createInternal(finalAmount, "test offer", e, features, Block.LivenetGenesisBlock.hash) - val payerKey = randomKey() - val request = OfferTypes.InvoiceRequest(offer, finalAmount, 1, features, payerKey, Block.LivenetGenesisBlock.hash) - val blindedRoute = RouteBlinding.create(randomKey(), listOf(randomKey().publicKey()), listOf(randomBytes(40).toByteVector())) - val paymentInfo = OfferTypes.PaymentInfo(channelUpdateDE.feeBaseMsat, channelUpdateDE.feeProportionalMillionths, channelUpdateDE.cltvExpiryDelta, channelUpdateDE.htlcMinimumMsat, channelUpdateDE.htlcMaximumMsat!!, Features.empty) - val path = Bolt12Invoice.Companion.PaymentBlindedContactInfo(OfferTypes.ContactInfo.BlindedPath(blindedRoute), paymentInfo) - val invoice = Bolt12Invoice(request, paymentPreimage, privE, 600, features, listOf(path)) - - val (amountAC, expiryAC, trampolineOnion) = OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket( - invoice, - trampolineHops, - PaymentOnion.FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, ByteVector32.Zeroes, null) - ) - assertEquals(amountBC, amountAC) - assertEquals(expiryBC, expiryAC) - - val (firstAmount, firstExpiry, onion) = OutgoingPaymentPacket.buildPacket( - paymentHash, - trampolineChannelHops, - PaymentOnion.FinalPayload.Standard.createTrampolinePayload(amountAC, amountAC, expiryAC, randomBytes32(), trampolineOnion.packet), - OnionRoutingPacket.PaymentPacketLength - ) - assertEquals(amountAB, firstAmount) - assertEquals(expiryAB, firstExpiry) - - val addB = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet) - val (_, packetC) = decryptChannelRelay(addB, privB) + // E uses a 1-hop blinded path from its LSP. + val (invoice, blindedRoute) = run { + val payerKey = randomKey() + val request = OfferTypes.InvoiceRequest(offer, finalAmount, 1, features, payerKey, Block.LivenetGenesisBlock.hash) + val paymentMetadata = OfferPaymentMetadata.V1(offer.offerId, finalAmount, paymentPreimage, payerKey.publicKey(), 1, currentTimestampMillis()) + val blindedPayloads = listOf( + RouteBlindingEncryptedData( + TlvStream( + RouteBlindingEncryptedDataTlv.OutgoingChannelId(channelUpdateDE.shortChannelId), + RouteBlindingEncryptedDataTlv.PaymentRelay(channelUpdateDE.cltvExpiryDelta, channelUpdateDE.feeProportionalMillionths, channelUpdateDE.feeBaseMsat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(finalExpiry, 1.msat), + ) + ), + RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(paymentMetadata.toPathId(privE)))), + ).map { it.write().byteVector() } + val blindedRouteDetails = RouteBlinding.create(randomKey(), listOf(d, e), blindedPayloads) + val paymentInfo = OfferTypes.PaymentInfo(channelUpdateDE.feeBaseMsat, channelUpdateDE.feeProportionalMillionths, channelUpdateDE.cltvExpiryDelta, channelUpdateDE.htlcMinimumMsat, channelUpdateDE.htlcMaximumMsat!!, Features.empty) + val path = Bolt12Invoice.Companion.PaymentBlindedContactInfo(OfferTypes.ContactInfo.BlindedPath(blindedRouteDetails.route), paymentInfo) + val invoice = Bolt12Invoice(request, paymentPreimage, blindedRouteDetails.blindedPrivateKey(privE), 600, features, listOf(path)) + assertEquals(invoice.nodeId, blindedRouteDetails.route.blindedNodeIds.last()) + Pair(invoice, blindedRouteDetails.route) + } - val addC = UpdateAddHtlc(randomBytes32(), 2, amountBC, paymentHash, expiryBC, packetC) - val (outerC, innerC, packetD) = decryptNodeRelay(addC, privC) - assertEquals(amountBC, outerC.amount) - assertEquals(amountBC, outerC.totalAmount) - assertEquals(expiryBC, outerC.expiry) - assertEquals(amountCD, innerC.amountToForward) - assertEquals(expiryCD, innerC.outgoingCltv) - assertEquals(d, innerC.outgoingNodeId) + // C pays that invoice using a trampoline node to relay to the invoice's blinded path. + val (firstAmount, firstExpiry, onion) = run { + val trampolineHop = NodeHop(d, invoice.nodeId, channelUpdateDE.cltvExpiryDelta, feeD) + val (trampolineAmount, trampolineExpiry, trampolineOnion) = OutgoingPaymentPacket.buildTrampolineToNonTrampolinePacket(invoice, trampolineHop, finalAmount, finalExpiry) + val trampolinePayload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineAmount, trampolineExpiry, randomBytes32(), trampolineOnion.packet) + OutgoingPaymentPacket.buildPacket(invoice.paymentHash, listOf(ChannelHop(c, d, channelUpdateCD)), trampolinePayload, OnionRoutingPacket.PaymentPacketLength) + } + assertEquals(amountCD, firstAmount) + assertEquals(expiryCD, firstExpiry) - // c forwards the trampoline payment to d. - val (amountD, expiryD, onionD) = OutgoingPaymentPacket.buildPacket( - paymentHash, - listOf(ChannelHop(c, d, channelUpdateCD)), - PaymentOnion.FinalPayload.Standard.createTrampolinePayload(amountCD, amountCD, expiryCD, randomBytes32(), packetD), - OnionRoutingPacket.PaymentPacketLength - ) - assertEquals(amountCD, amountD) - assertEquals(expiryCD, expiryD) - val addD = UpdateAddHtlc(randomBytes32(), 3, amountD, paymentHash, expiryD, onionD.packet) + // D decrypts the onion that contains a blinded path in the trampoline onion. + val addD = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet) val (outerD, innerD, _) = decryptRelayToBlinded(addD, privD) assertEquals(amountCD, outerD.amount) assertEquals(amountCD, outerD.totalAmount) assertEquals(expiryCD, outerD.expiry) assertEquals(finalAmount, innerD.amountToForward) assertEquals(expiryDE, innerD.outgoingCltv) - assertEquals(listOf(path), innerD.outgoingBlindedPaths) + assertEquals(listOf(blindedRoute), innerD.outgoingBlindedPaths.map { it.route.route }) assertEquals(invoice.features.toByteArray().toByteVector(), innerD.invoiceFeatures) + + // D is the introduction node of the blinded path: it can decrypt the first blinded payload and relay to E. + val addE = run { + val (dataD, blindingE) = RouteBlinding.decryptPayload(privD, blindedRoute.blindingKey, blindedRoute.encryptedPayloads.first()).right!! + val payloadD = RouteBlindingEncryptedData.read(dataD.toByteArray()).right!! + assertEquals(channelUpdateDE.shortChannelId, payloadD.outgoingChannelId) + // D would normally create this payload based on the blinded path's payment_info field. + val payloadE = PaymentOnion.FinalPayload.Blinded( + TlvStream( + OnionPaymentPayloadTlv.AmountToForward(finalAmount), + OnionPaymentPayloadTlv.TotalAmount(finalAmount), + OnionPaymentPayloadTlv.OutgoingCltv(finalExpiry), + OnionPaymentPayloadTlv.EncryptedRecipientData(blindedRoute.encryptedPayloads.last()), + ), + // This dummy value is ignored when creating the htlc (D is not the recipient). + RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(ByteVector("deadbeef")))) + ) + val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) + val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(addD.paymentHash, listOf(blindedHop), payloadE, OnionRoutingPacket.PaymentPacketLength) + UpdateAddHtlc(randomBytes32(), 2, amountE, addD.paymentHash, expiryE, onionE.packet, blindingE) + } + + // E can correctly decrypt the blinded payment. + val payloadE = IncomingPaymentPacket.decrypt(addE, privE).right!! + assertIs(payloadE) + val paymentMetadata = OfferPaymentMetadata.fromPathId(e, payloadE.pathId) + assertNotNull(paymentMetadata) + assertEquals(offer.offerId, paymentMetadata.offerId) + assertEquals(paymentMetadata.paymentHash, invoice.paymentHash) + } + + @Test + fun `build a payment to a blinded path`() { + val (addE, payloadE) = createBlindedHtlc() + // E can correctly decrypt the blinded payment. + assertEquals(payloadE, IncomingPaymentPacket.decrypt(addE, privE).right) } @Test @@ -477,6 +521,29 @@ class PaymentPacketTestsCommon : LightningTestSuite() { assertEquals(InvalidOnionHmac.code, failure.left!!.code) } + @Test + fun `fail to decrypt when blinded route data is invalid`() { + val paymentMetadata = OfferPaymentMetadata.V1(randomBytes32(), finalAmount, paymentPreimage, randomKey().publicKey(), 1, currentTimestampMillis()) + val blindedPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(paymentMetadata.toPathId(privE)))) + val blindedRoute = RouteBlinding.create(randomKey(), listOf(e), listOf(blindedPayload.write().byteVector())).route + val payloadE = PaymentOnion.FinalPayload.Blinded( + TlvStream( + OnionPaymentPayloadTlv.AmountToForward(finalAmount), + OnionPaymentPayloadTlv.TotalAmount(finalAmount), + OnionPaymentPayloadTlv.OutgoingCltv(finalExpiry), + // The encrypted data is invalid. + OnionPaymentPayloadTlv.EncryptedRecipientData(blindedRoute.encryptedPayloads.last().reversed()), + ), + blindedPayload + ) + val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) + val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(paymentMetadata.paymentHash, listOf(blindedHop), payloadE, OnionRoutingPacket.PaymentPacketLength) + val addE = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey) + val failure = IncomingPaymentPacket.decrypt(addE, privE) + assertTrue(failure.isLeft) + assertEquals(failure.left, InvalidOnionBlinding(hash(addE.onionRoutingPacket))) + } + @Test fun `fail to decrypt at the final node when amount has been modified by next-to-last node`() { val (firstAmount, firstExpiry, onion) = OutgoingPaymentPacket.buildPacket( @@ -503,6 +570,22 @@ class PaymentPacketTestsCommon : LightningTestSuite() { assertEquals(Either.Left(FinalIncorrectCltvExpiry(firstExpiry - CltvExpiryDelta(12))), failure) } + @Test + fun `fail to decrypt blinded payment at the final node when amount is too low`() { + val (addE, _) = createBlindedHtlc() + // E receives a smaller amount than expected and rejects the payment. + val failure = IncomingPaymentPacket.decrypt(addE.copy(amountMsat = addE.amountMsat - 1.msat), privE).left + assertEquals(InvalidOnionBlinding(hash(addE.onionRoutingPacket)), failure) + } + + @Test + fun `fail to decrypt blinded payment at the final node when expiry is too low`() { + val (addE, _) = createBlindedHtlc() + // E receives a smaller expiry than expected and rejects the payment. + val failure = IncomingPaymentPacket.decrypt(addE.copy(cltvExpiry = addE.cltvExpiry - CltvExpiryDelta(1)), privE).left + assertEquals(InvalidOnionBlinding(hash(addE.onionRoutingPacket)), failure) + } + @Test fun `fail to decrypt at the final trampoline node when amount has been modified by next-to-last trampoline`() { val (amountAC, expiryAC, trampolineOnion) = OutgoingPaymentPacket.buildPacket(