Skip to content

Commit

Permalink
Rework OfferPaymentMetadata
Browse files Browse the repository at this point in the history
While this is short-lived in the `path_id` field of an invoice's blinded
path, this is also permanently recorded in our payments DB. We thus need
to use a versioned encoding to ensure that we're able to change the data
we store in `path_id`s while still being able to read past data.
  • Loading branch information
t-bast committed Apr 17, 2024
1 parent 63d3a5e commit 6efbd9f
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,10 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
}

when (val finalPayload = paymentPart.finalPayload) {
is PaymentOnion.FinalPayload.Standard ->
if (finalPayload.paymentMetadata == null)
logger.info { "payment received (${payment.amountReceived}) without payment metadata" }
else
logger.info { "payment received (${payment.amountReceived}) with payment metadata (${finalPayload.paymentMetadata})" }
is PaymentOnion.FinalPayload.Standard -> when (finalPayload.paymentMetadata) {
null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" }
else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata (${finalPayload.paymentMetadata})" }
}
is PaymentOnion.FinalPayload.Blinded -> logger.info { "payment received (${payment.amountReceived}) with blinded route" }
}
val htlcParts = payment.parts.filterIsInstance<HtlcPart>()
Expand Down Expand Up @@ -304,6 +303,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
pending.remove(paymentPart.paymentHash)
val received = IncomingPayment.Received(receivedWith = receivedWith)
if (incomingPayment.origin is IncomingPayment.Origin.Offer) {
// We didn't store the Bolt 12 invoice in our DB when receiving the invoice_request (to protect against DoS).
// We need to create the DB entry now otherwise the payment won't be recorded.
db.addIncomingPayment(incomingPayment.preimage, incomingPayment.origin)
}
db.receivePayment(paymentPart.paymentHash, received.receivedWith)
Expand Down Expand Up @@ -375,33 +376,40 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
else -> Either.Right(incomingPayment)
}
}
is PaymentOnion.FinalPayload.Blinded -> try {
val metadata = OfferPaymentMetadata.read(nodeParams.nodeId, ByteArrayInput(finalPayload.pathId.toByteArray()))
val incomingPayment = db.getIncomingPayment(paymentPart.paymentHash) ?: IncomingPayment(metadata.preimage, IncomingPayment.Origin.Offer(metadata), null)
return when {
!paymentPart.paymentHash.toByteArray().contentEquals(Crypto.sha256(metadata.preimage)) -> {
logger.warning { "payment for which we don't have a preimage" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
paymentPart.totalAmount < metadata.amount -> {
logger.warning { "invalid amount (underpayment): ${paymentPart.totalAmount}, expected: ${metadata.amount}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) -> {
logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
metadata.createdAtMillis + nodeParams.bolt12invoiceExpiry.inWholeMilliseconds < currentTimestampMillis() && incomingPayment.received == null -> {
logger.warning { "the invoice is expired" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
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)) {
null -> {
logger.warning { "invalid path_id: ${finalPayload.pathId.toHex()}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))
}
else -> {
Either.Right(incomingPayment)
val incomingPayment = db.getIncomingPayment(paymentPart.paymentHash) ?: IncomingPayment(metadata.preimage, IncomingPayment.Origin.Offer(metadata), null)
when {
incomingPayment.origin !is IncomingPayment.Origin.Offer -> {
logger.warning { "unsupported payment type: ${incomingPayment.origin::class}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
paymentPart.paymentHash != metadata.paymentHash -> {
logger.warning { "payment for which we don't have a preimage" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
paymentPart.totalAmount < metadata.amount -> {
logger.warning { "invalid amount (underpayment): ${paymentPart.totalAmount}, expected: ${metadata.amount}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) -> {
logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
metadata.createdAtMillis + nodeParams.bolt12invoiceExpiry.inWholeMilliseconds < currentTimestampMillis() && incomingPayment.received == null -> {
logger.warning { "the invoice is expired" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
}
else -> Either.Right(incomingPayment)
}
}
}
} catch (ex: Throwable) {
logger.warning { "blinded payment to route that we did not create" }
return Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))
}
}
}
Expand Down
143 changes: 100 additions & 43 deletions src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,114 @@ 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.utils.toByteVector
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.wire.LightningCodecs

/**
* OfferPaymentMetadata is an alternative to storing a Bolt12Invoice in a database which would expose us to DoS.
* It contains the important bits from the invoice that we need to validate an incoming payment and is given to the
* payer signed and encrypted. When making the payment, the OfferPaymentMetadata will be in the pathId and will allow us
* to know what the payment is for and validate it.
* The flow for Bolt 12 offer payments is the following:
*
* - we create a long-lived, reusable offer
* - whenever someone wants to pay that offer, they send us an invoice_request
* - we create a Bolt 12 invoice and send it back to the payer
* - they send a payment for the corresponding payment_hash
*
* When receiving the invoice_request, we have no guarantee that the sender will attempt the payment.
* If we saved the corresponding invoice in our DB, that could be used as a DoS vector to fill our DB with unpaid invoices.
* To avoid that, we don't save anything in our DB at that step, and instead include a subset of the invoice fields in the
* invoice's blinded path. A valid payment for that invoice will include that encrypted data, which contains the metadata
* we want to store for that payment.
*/
data class OfferPaymentMetadata(
val offerId: ByteVector32,
val preimage: ByteVector32,
val payerKey: PublicKey,
val amount: MilliSatoshi,
val quantity: Long,
val createdAtMillis: Long) {

fun write(privateKey: PrivateKey, out: Output) {
val tmp = ByteArrayOutput()
LightningCodecs.writeBytes(offerId, tmp)
LightningCodecs.writeBytes(preimage, tmp)
LightningCodecs.writeBytes(payerKey.value, tmp)
LightningCodecs.writeU64(amount.toLong(), tmp)
LightningCodecs.writeU64(quantity, tmp)
LightningCodecs.writeU64(createdAtMillis, tmp)
val metadata = tmp.toByteArray()
val signature = Crypto.sign(Crypto.sha256(metadata), privateKey)
LightningCodecs.writeBytes(signature, out)
LightningCodecs.writeBytes(metadata, out)
sealed class OfferPaymentMetadata {
abstract val version: Byte
abstract val offerId: ByteVector32
abstract val amount: MilliSatoshi
abstract val preimage: ByteVector32
abstract val createdAtMillis: Long
val paymentHash: ByteVector32 get() = preimage.sha256()

/** Encode into a format that can be stored in the payments DB. */
fun encode(): ByteVector {
val out = ByteArrayOutput()
LightningCodecs.writeByte(this.version.toInt(), out)
when (this) {
is V1 -> this.write(out)
}
return out.toByteArray().byteVector()
}

fun write(privateKey: PrivateKey): ByteVector {
val tmp = ByteArrayOutput()
write(privateKey, tmp)
return tmp.toByteArray().toByteVector()
/** Encode into a path_id that must be included in the [Bolt12Invoice]'s blinded path. */
fun toPathId(nodeKey: PrivateKey): ByteVector = when (this) {
is V1 -> {
val encoded = this.encode()
val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey)
encoded + signature
}
}

/** In this first version, we simply sign the payment metadata to verify its authenticity when receiving the payment. */
data class V1(
override val offerId: ByteVector32,
override val amount: MilliSatoshi,
override val preimage: ByteVector32,
val payerKey: PublicKey,
val quantity: Long,
override val createdAtMillis: Long
) : OfferPaymentMetadata() {
override val version: Byte get() = 1

fun write(out: Output) {
LightningCodecs.writeBytes(offerId, out)
LightningCodecs.writeU64(amount.toLong(), out)
LightningCodecs.writeBytes(preimage, out)
LightningCodecs.writeBytes(payerKey.value, out)
LightningCodecs.writeU64(quantity, out)
LightningCodecs.writeU64(createdAtMillis, out)
}

companion object {
fun read(input: Input): V1 = V1(
offerId = LightningCodecs.bytes(input, 32).byteVector32(),
amount = LightningCodecs.u64(input).msat,
preimage = LightningCodecs.bytes(input, 32).byteVector32(),
payerKey = PublicKey(LightningCodecs.bytes(input, 33)),
quantity = LightningCodecs.u64(input),
createdAtMillis = LightningCodecs.u64(input),
)
}
}

companion object {
fun read(publicKey: PublicKey, input: Input): OfferPaymentMetadata {
val signature = ByteVector64(LightningCodecs.bytes(input, 64))
val metadata = LightningCodecs.bytes(input, input.availableBytes)
// We verify the signature to ensure that we generated a matching invoice and not someone else.
require(Crypto.verifySignature(Crypto.sha256(metadata), signature, publicKey))
val metadataInput = ByteArrayInput(metadata)
return OfferPaymentMetadata(
ByteVector32(LightningCodecs.bytes(metadataInput, 32)),
ByteVector32(LightningCodecs.bytes(metadataInput, 32)),
PublicKey(LightningCodecs.bytes(metadataInput, 33)),
MilliSatoshi(LightningCodecs.u64(metadataInput)),
LightningCodecs.u64(metadataInput),
LightningCodecs.u64(metadataInput))
/**
* Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB).
* This function should only be used on data that comes from a trusted source, otherwise it may throw.
*/
fun decode(encoded: ByteVector): OfferPaymentMetadata {
val input = ByteArrayInput(encoded.toByteArray())
return when (val version = LightningCodecs.byte(input)) {
1 -> V1.read(input)
else -> throw IllegalArgumentException("unknown offer payment metadata version: $version")
}
}

/**
* 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? {
if (pathId.isEmpty()) return null
val input = ByteArrayInput(pathId.toByteArray())
when (LightningCodecs.byte(input)) {
1 -> {
if (input.availableBytes != 185) return null
val metadata = LightningCodecs.bytes(input, 121)
val signature = LightningCodecs.bytes(input, 64).byteVector64()
// Note that the signature includes the version byte.
if (!Crypto.verifySignature(Crypto.sha256(pathId.take(122)), signature, nodeId)) 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))
}
else -> return null
}
}
}
}
}
Loading

0 comments on commit 6efbd9f

Please sign in to comment.