@@ -8,8 +8,10 @@ import fr.acinq.lightning.Lightning.randomBytes
88import fr.acinq.lightning.Lightning.randomBytes32
99import fr.acinq.lightning.Lightning.randomBytes64
1010import fr.acinq.lightning.Lightning.randomKey
11+ import fr.acinq.lightning.channel.ChannelCommand
1112import fr.acinq.lightning.channel.states.Channel
1213import fr.acinq.lightning.crypto.RouteBlinding
14+ import fr.acinq.lightning.crypto.sphinx.FailurePacket
1315import fr.acinq.lightning.crypto.sphinx.PacketAndSecrets
1416import fr.acinq.lightning.crypto.sphinx.Sphinx
1517import fr.acinq.lightning.crypto.sphinx.Sphinx.hash
@@ -123,6 +125,19 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
123125 return Pair (outerPayload, innerPayload)
124126 }
125127
128+ // Wallets don't need to decrypt onions where they're the introduction node of a blinded path, but it's useful to test that encryption works correctly.
129+ fun decryptBlindedChannelRelay (add : UpdateAddHtlc , privateKey : PrivateKey ): Pair <OnionRoutingPacket , PublicKey > {
130+ val decrypted = Sphinx .peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!!
131+ assertFalse(decrypted.isLastPacket)
132+ val payload = PaymentOnion .PerHopPayload .read(decrypted.payload.toByteArray()).right!!
133+ val pathKey = payload.get<OnionPaymentPayloadTlv .PathKey >()?.publicKey ? : add.pathKey
134+ assertNotNull(pathKey)
135+ val encryptedData = payload.get<OnionPaymentPayloadTlv .EncryptedRecipientData >()!! .data
136+ val nextPathKey = RouteBlinding .decryptPayload(privateKey, pathKey, encryptedData).map { it.second }.right
137+ assertNotNull(nextPathKey)
138+ return Pair (decrypted.nextPacket, nextPathKey)
139+ }
140+
126141 // Wallets don't need to decrypt onions for intermediate nodes, but it's useful to test that encryption works correctly.
127142 fun decryptRelayToBlinded (add : UpdateAddHtlc , privateKey : PrivateKey ): Pair <PaymentOnion .FinalPayload , PaymentOnion .RelayToBlindedPayload > {
128143 val decrypted = Sphinx .peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!!
@@ -504,6 +519,110 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
504519 assertEquals(InvalidOnionBlinding (hash(addD.onionRoutingPacket)), failure)
505520 }
506521
522+ @Test
523+ fun `build htlc failure onion` () {
524+ // B sends a payment B -> C -> D.
525+ val (amountC, expiryC, onionC) = run {
526+ val payloadD = PaymentOnion .FinalPayload .Standard .createMultiPartPayload(finalAmount, finalAmount, finalExpiry, paymentSecret, paymentMetadata = null )
527+ encryptChannelRelay(paymentHash, listOf (ChannelHop (b, c, channelUpdateBC), ChannelHop (c, d, channelUpdateCD)), payloadD)
528+ }
529+ assertEquals(amountBC, amountC)
530+ assertEquals(expiryBC, expiryC)
531+
532+ // C relays the payment to D.
533+ val addC = UpdateAddHtlc (randomBytes32(), 2 , amountC, paymentHash, expiryC, onionC.packet)
534+ val (payloadC, packetD) = decryptChannelRelay(addC, privC)
535+ val addD = UpdateAddHtlc (randomBytes32(), 3 , payloadC.amountToForward, paymentHash, payloadC.outgoingCltv, packetD)
536+ assertNotNull(IncomingPaymentPacket .decrypt(addD, privD).right)
537+ val willAddD = WillAddHtlc (Block .RegtestGenesisBlock .hash, randomBytes32(), payloadC.amountToForward, paymentHash, payloadC.outgoingCltv, packetD)
538+ assertNotNull(IncomingPaymentPacket .decrypt(willAddD, privD))
539+
540+ // D returns a failure.
541+ val failure = IncorrectOrUnknownPaymentDetails (finalAmount, currentBlockCount)
542+ val encryptedFailuresD = run {
543+ val encryptedFailureD = OutgoingPaymentPacket .buildHtlcFailure(privD, paymentHash, addD.onionRoutingPacket, addD.pathKey, ChannelCommand .Htlc .Settlement .Fail .Reason .Failure (failure)).right
544+ assertNotNull(encryptedFailureD)
545+ val willFailD = OutgoingPaymentPacket .buildWillAddHtlcFailure(privD, willAddD, failure)
546+ assertIs<WillFailHtlc >(willFailD)
547+ listOf (encryptedFailureD, willFailD.reason)
548+ }
549+ encryptedFailuresD.forEach { encryptedFailureD ->
550+ // C cannot decrypt the failure: it re-encrypts and relays that failure to B.
551+ val encryptedFailureC = OutgoingPaymentPacket .buildHtlcFailure(privC, paymentHash, addC.onionRoutingPacket, addC.pathKey, ChannelCommand .Htlc .Settlement .Fail .Reason .Bytes (encryptedFailureD)).right
552+ assertNotNull(encryptedFailureC)
553+ // B decrypts the failure.
554+ val decrypted = FailurePacket .decrypt(encryptedFailureC.toByteArray(), onionC.sharedSecrets)
555+ assertTrue(decrypted.isSuccess)
556+ assertEquals(d, decrypted.get().originNode)
557+ assertEquals(failure, decrypted.get().failureMessage)
558+ }
559+ }
560+
561+ @Test
562+ fun `build htlc failure onion -- blinded payment` () {
563+ // D creates a blinded path C -> D.
564+ val offer = OfferTypes .Offer .createNonBlindedOffer(finalAmount, " test offer" , d, Features (Feature .BasicMultiPartPayment to FeatureSupport .Optional ), Block .RegtestGenesisBlock .hash)
565+ val pathId = OfferPaymentMetadata .V1 (offer.offerId, finalAmount, paymentPreimage, randomKey().publicKey(), " hello" , 1 , currentTimestampMillis()).toPathId(privD)
566+ val blindedPayloadD = RouteBlindingEncryptedData (TlvStream (RouteBlindingEncryptedDataTlv .PathId (pathId)))
567+ val blindedPayloadC = RouteBlindingEncryptedData (
568+ TlvStream (
569+ RouteBlindingEncryptedDataTlv .OutgoingChannelId (channelUpdateCD.shortChannelId),
570+ RouteBlindingEncryptedDataTlv .PaymentRelay (channelUpdateCD.cltvExpiryDelta, channelUpdateCD.feeProportionalMillionths, channelUpdateCD.feeBaseMsat),
571+ RouteBlindingEncryptedDataTlv .PaymentConstraints (finalExpiry, 1 .msat),
572+ )
573+ )
574+ val blindedRoute = RouteBlinding .create(randomKey(), listOf (c, d), listOf (blindedPayloadC, blindedPayloadD).map { it.write().byteVector() }).route
575+
576+ // B sends a blinded payment B -> C -> D using this blinded path.
577+ val onionC = run {
578+ val payloadD = PaymentOnion .FinalPayload .Blinded (
579+ TlvStream (
580+ OnionPaymentPayloadTlv .AmountToForward (finalAmount),
581+ OnionPaymentPayloadTlv .TotalAmount (finalAmount),
582+ OnionPaymentPayloadTlv .OutgoingCltv (finalExpiry),
583+ OnionPaymentPayloadTlv .EncryptedRecipientData (blindedRoute.encryptedPayloads.last()),
584+ ),
585+ blindedPayloadD
586+ )
587+ val payloadC = PaymentOnion .BlindedChannelRelayPayload (
588+ TlvStream (
589+ OnionPaymentPayloadTlv .EncryptedRecipientData (blindedRoute.encryptedPayloads.first()),
590+ OnionPaymentPayloadTlv .PathKey (blindedRoute.firstPathKey),
591+ )
592+ )
593+ OutgoingPaymentPacket .buildOnion(listOf (c, blindedRoute.blindedNodeIds.last()), listOf (payloadC, payloadD), paymentHash, OnionRoutingPacket .PaymentPacketLength )
594+ }
595+
596+ // C relays the payment to D.
597+ val addC = UpdateAddHtlc (randomBytes32(), 2 , amountBC, paymentHash, expiryBC, onionC.packet)
598+ val (packetD, pathKeyD) = decryptBlindedChannelRelay(addC, privC)
599+ val addD = UpdateAddHtlc (randomBytes32(), 3 , amountCD, paymentHash, expiryCD, packetD, pathKeyD, fundingFee = null )
600+ assertNotNull(IncomingPaymentPacket .decrypt(addD, privD).right)
601+ val willAddD = WillAddHtlc (Block .RegtestGenesisBlock .hash, randomBytes32(), amountCD, paymentHash, expiryCD, packetD, pathKeyD)
602+ assertNotNull(IncomingPaymentPacket .decrypt(willAddD, privD).right)
603+
604+ // D returns a failure: note that it is not a blinded failure, since there is no need to protect the blinded path against probing.
605+ // This ensures that payers get a meaningful error from wallet recipients.
606+ val failure = IncorrectOrUnknownPaymentDetails (finalAmount, currentBlockCount)
607+ val encryptedFailuresD = run {
608+ val encryptedFailureD = OutgoingPaymentPacket .buildHtlcFailure(privD, paymentHash, addD.onionRoutingPacket, addD.pathKey, ChannelCommand .Htlc .Settlement .Fail .Reason .Failure (failure)).right
609+ assertNotNull(encryptedFailureD)
610+ val willFailD = OutgoingPaymentPacket .buildWillAddHtlcFailure(privD, willAddD, failure)
611+ assertIs<WillFailHtlc >(willFailD)
612+ listOf (encryptedFailureD, willFailD.reason)
613+ }
614+ encryptedFailuresD.forEach { encryptedFailureD ->
615+ // C cannot decrypt the failure: it re-encrypts and relays that failure to B.
616+ val encryptedFailureC = OutgoingPaymentPacket .buildHtlcFailure(privC, paymentHash, addC.onionRoutingPacket, addC.pathKey, ChannelCommand .Htlc .Settlement .Fail .Reason .Bytes (encryptedFailureD)).right
617+ assertNotNull(encryptedFailureC)
618+ // B decrypts the failure.
619+ val decrypted = FailurePacket .decrypt(encryptedFailureC.toByteArray(), onionC.sharedSecrets)
620+ assertTrue(decrypted.isSuccess)
621+ assertEquals(blindedRoute.blindedNodeIds.last(), decrypted.get().originNode)
622+ assertEquals(failure, decrypted.get().failureMessage)
623+ }
624+ }
625+
507626 @Test
508627 fun `prune outgoing blinded paths` () {
509628 // We create an invoice with a large number of blinded paths.
0 commit comments