Skip to content

Commit

Permalink
Add signature when including invreq_bip_353_name
Browse files Browse the repository at this point in the history
When including a BIP 353 HRN, we also require including a signature of
the `invoice_request` using one of the keys from the offer stored in the
BIP 353 DNS record. We only add the BIP 353 HRN to our contacts list
after verifying that it matches the offer we retrieved.
  • Loading branch information
t-bast committed Jan 7, 2025
1 parent a907cde commit 25ab7ed
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fr.acinq.lightning.payment

import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.core.*
Expand Down Expand Up @@ -29,6 +30,26 @@ data class ContactAddress(val name: String, val domain: String) {
}
}

/**
* When we receive an invoice_request containing a contact address, we don't immediately fetch the offer from
* the BIP 353 address, because this could otherwise be used as a DoS vector since we haven't received a payment yet.
*
* After receiving the payment, we resolve the BIP 353 address to store the contact.
* In the invoice_request, they committed to the signing key used for their offer.
* We verify that the offer uses this signing key, otherwise the BIP 353 address most likely doesn't belong to them.
*/
data class UnverifiedContactAddress(val address: ContactAddress, val expectedOfferSigningKey: PublicKey) {
/**
* Verify that the offer obtained by resolving the BIP 353 address matches the invoice_request commitment.
* If this returns false, it means that either:
* - the contact address doesn't belong to the node
* - or they changed the signing key of the offer associated with their BIP 353 address
* Since the second case should be very infrequent, it's more likely that the remote node is malicious
* and we shouldn't store them in our contacts list.
*/
fun verify(offer: OfferTypes.Offer): Boolean = expectedOfferSigningKey == offer.issuerId || (offer.paths?.map { it.nodeId }?.toSet() ?: setOf()).contains(expectedOfferSigningKey)
}

/**
* Contact secrets are used to mutually authenticate payments.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ sealed class OfferPaymentMetadata {
val quantity: Long,
val contactSecret: ByteVector32?,
val payerOffer: OfferTypes.Offer?,
val payerAddress: ContactAddress?,
val payerAddress: UnverifiedContactAddress?,
override val createdAtMillis: Long
) : OfferPaymentMetadata() {
override val version: Byte get() = 2
Expand All @@ -131,6 +131,16 @@ sealed class OfferPaymentMetadata {
}
}

private fun writeOptionalContactAddress(payerAddress: UnverifiedContactAddress?, out: Output) = when (payerAddress) {
null -> LightningCodecs.writeU16(0, out)
else -> {
val address = payerAddress.address.toString().encodeToByteArray()
LightningCodecs.writeU16(address.size + 33, out)
LightningCodecs.writeBytes(address, out)
LightningCodecs.writeBytes(payerAddress.expectedOfferSigningKey.value, out)
}
}

fun write(out: Output) {
LightningCodecs.writeBytes(offerId, out)
LightningCodecs.writeU64(amount.toLong(), out)
Expand All @@ -140,7 +150,7 @@ sealed class OfferPaymentMetadata {
LightningCodecs.writeU64(quantity, out)
writeOptionalBytes(contactSecret?.toByteArray(), out)
writeOptionalBytes(payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records) }, out)
writeOptionalBytes(payerAddress?.toString()?.encodeToByteArray(), out)
writeOptionalContactAddress(payerAddress, out)
LightningCodecs.writeU64(createdAtMillis, out)
}

Expand All @@ -150,6 +160,14 @@ sealed class OfferPaymentMetadata {
else -> LightningCodecs.bytes(input, size)
}

private fun readOptionalContactAddress(input: Input): UnverifiedContactAddress? = when (val size = LightningCodecs.u16(input)) {
0 -> null
else -> ContactAddress.fromString(LightningCodecs.bytes(input, size - 33).decodeToString())?.let { address ->
val offerKey = PublicKey(LightningCodecs.bytes(input, 33))
UnverifiedContactAddress(address, offerKey)
}
}

fun read(input: Input): V2 {
val offerId = LightningCodecs.bytes(input, 32).byteVector32()
val amount = LightningCodecs.u64(input).msat
Expand All @@ -159,7 +177,7 @@ sealed class OfferPaymentMetadata {
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 payerAddress = readOptionalContactAddress(input)
val createdAtMillis = LightningCodecs.u64(input)
return V2(offerId, amount, preimage, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress, createdAtMillis)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.crypto.RouteBlinding
import fr.acinq.lightning.message.OnionMessages
import fr.acinq.lightning.payment.ContactAddress
import fr.acinq.lightning.payment.UnverifiedContactAddress

/**
* Lightning Bolt 12 offers
Expand Down Expand Up @@ -460,6 +461,28 @@ object OfferTypes {
}
}

/**
* When [[InvoiceRequestPayerAddress]] is included, the invoice request must be signed with the signing key of the offer matching the BIP 353 address.
* This proves that the payer really owns this BIP 353 address.
* See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
*/
data class InvoiceRequestPayerAddressSignature(val offerSigningKey: PublicKey, val signature: ByteVector64) : InvoiceRequestTlv() {
override val tag: Long get() = InvoiceRequestPayerAddressSignature.tag
override fun write(out: Output) {
LightningCodecs.writeBytes(offerSigningKey.value, out)
LightningCodecs.writeBytes(signature, out)
}

companion object : TlvValueReader<InvoiceRequestPayerAddressSignature> {
const val tag: Long = 2_000_001_735L
override fun read(input: Input): InvoiceRequestPayerAddressSignature {
val offerSigningKey = PublicKey(LightningCodecs.bytes(input, 33))
val signature = LightningCodecs.bytes(input, 64).byteVector64()
return InvoiceRequestPayerAddressSignature(offerSigningKey, signature)
}
}
}

/**
* Payment paths to send the payment to.
*/
Expand Down Expand Up @@ -919,7 +942,7 @@ object OfferTypes {
val payerNote: String? = records.get<InvoiceRequestPayerNote>()?.note
val contactSecret: ByteVector32? = records.get<InvoiceRequestContactSecret>()?.contactSecret
val payerOffer: Offer? = records.get<InvoiceRequestPayerOffer>()?.offer
val payerAddress: ContactAddress? = records.get<InvoiceRequestPayerAddress>()?.address
val payerAddress: UnverifiedContactAddress? = records.get<InvoiceRequestPayerAddress>()?.let { pa -> records.get<InvoiceRequestPayerAddressSignature>()?.let { ps -> UnverifiedContactAddress(pa.address, ps.offerSigningKey) } }
private val signature: ByteVector64 = records.get<Signature>()!!.signature

fun isValid(): Boolean =
Expand All @@ -928,17 +951,23 @@ object OfferTypes {
offer.chains.contains(chain) &&
((offer.quantityMax == null && quantity_opt == null) || (offer.quantityMax != null && quantity_opt != null && quantity <= offer.quantityMax)) &&
Features.areCompatible(offer.features, features) &&
checkPayerAddressSignature() &&
checkSignature()

fun requestedAmount(): MilliSatoshi? = amount ?: offer.amount?.let { it * quantity }

fun checkSignature(): Boolean =
verifySchnorr(
signatureTag,
rootHash(removeSignature(records)),
signature,
payerId
)
private fun checkPayerAddressSignature(): Boolean = when (val ps = records.get<InvoiceRequestPayerAddressSignature>()) {
null -> true
else -> {
// The payer address signature covers the invoice request without its top-level signature.
// Note that the standard invoice request signature includes the InvoiceRequestPayerAddressSignature field.
val signedTlvs = TlvStream(records.records.filter { it !is Signature && it !is InvoiceRequestPayerAddressSignature }.toSet(), records.unknown)
val signatureTag = ByteVector(("lightning" + "invoice_request" + "invreq_payer_bip_353_signature").encodeToByteArray())
verifySchnorr(signatureTag, rootHash(signedTlvs), ps.signature, ps.offerSigningKey)
}
}

fun checkSignature(): Boolean = verifySchnorr(signatureTag, rootHash(removeSignature(records)), signature, payerId)

fun encode(): String {
val data = tlvSerializer.write(records)
Expand All @@ -951,8 +980,7 @@ object OfferTypes {

companion object {
val hrp = "lnr"
val signatureTag: ByteVector =
ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray())
val signatureTag: ByteVector = ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray())

/**
* Create a request to fetch an invoice for a given offer.
Expand Down Expand Up @@ -999,9 +1027,10 @@ object OfferTypes {
is Left -> return Left(offer.value)
is Right -> {}
}
if (records.get<InvoiceRequestMetadata>() == null) return Left(MissingRequiredTlv(0L))
if (records.get<InvoiceRequestPayerId>() == null) return Left(MissingRequiredTlv(88))
if (records.get<Signature>() == null) return Left(MissingRequiredTlv(240))
if (records.get<InvoiceRequestMetadata>() == null) return Left(MissingRequiredTlv(InvoiceRequestMetadata.tag))
if (records.get<InvoiceRequestAmount>() == null && records.get<OfferAmount>() == null) return Left(MissingRequiredTlv(InvoiceRequestAmount.tag))
if (records.get<InvoiceRequestPayerId>() == null) return Left(MissingRequiredTlv(InvoiceRequestPayerId.tag))
if (records.get<Signature>() == null) return Left(MissingRequiredTlv(Signature.tag))
if (records.unknown.any { !isInvoiceRequestTlv(it) }) return Left(ForbiddenTlv(records.unknown.find { !isInvoiceRequestTlv(it) }!!.tag))
return Right(InvoiceRequest(records))
}
Expand Down Expand Up @@ -1031,6 +1060,7 @@ object OfferTypes {
InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader<InvoiceRequestTlv>,
InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader<InvoiceRequestTlv>,
InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader<InvoiceRequestTlv>,
InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader<InvoiceRequestTlv>,
Signature.tag to Signature as TlvValueReader<InvoiceRequestTlv>,
)
)
Expand Down Expand Up @@ -1073,6 +1103,7 @@ object OfferTypes {
InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader<InvoiceTlv>,
InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader<InvoiceTlv>,
InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader<InvoiceTlv>,
InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader<InvoiceTlv>,
// Invoice part
InvoicePaths.tag to InvoicePaths as TlvValueReader<InvoiceTlv>,
InvoiceBlindedPay.tag to InvoiceBlindedPay as TlvValueReader<InvoiceTlv>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import fr.acinq.lightning.wire.OfferTypes
import fr.acinq.lightning.wire.TlvStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class ContactsTestsCommon : LightningTestSuite() {

Expand Down Expand Up @@ -35,6 +37,9 @@ class ContactsTestsCommon : LightningTestSuite() {
assertEquals("810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8", contactSecretAlice.primarySecret.toHex())
val contactSecretBob = Contacts.computeContactSecret(bobOfferAndKey, aliceOfferAndKey.offer)
assertEquals(contactSecretAlice, contactSecretBob)
val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("[email protected]")!!, bobOfferAndKey.privateKey.publicKey())
assertTrue(payerAddress.verify(bobOfferAndKey.offer))
assertFalse(payerAddress.verify(aliceOfferAndKey.offer))
}
run {
// The remote offer contains an issuer_id and a blinded path.
Expand All @@ -53,7 +58,8 @@ class ContactsTestsCommon : LightningTestSuite() {
assertEquals("4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c", contactSecretAlice.primarySecret.toHex())
val contactSecretBob = Contacts.computeContactSecret(OfferTypes.OfferAndKey(bobOffer, issuerKey), aliceOfferAndKey.offer)
assertEquals(contactSecretAlice, contactSecretBob)

val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("[email protected]")!!, issuerKey.publicKey())
assertTrue(payerAddress.verify(bobOffer))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class OfferPaymentMetadataTestsCommon {
}

@Test
fun `encode - decode v2 metadata with contact information`() {
fun `encode - decode v2 metadata with contact offer`() {
val nodeKey = randomKey()
val preimage = randomBytes32()
val paymentHash = Crypto.sha256(preimage).byteVector32()
Expand Down Expand Up @@ -111,6 +111,29 @@ class OfferPaymentMetadataTestsCommon {
assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash))
}

@Test
fun `encode - decode v2 metadata with contact address`() {
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 = "hello there",
quantity = 1,
contactSecret = randomBytes32(),
payerOffer = null,
payerAddress = UnverifiedContactAddress(ContactAddress.fromString("[email protected]")!!, randomKey().publicKey()),
createdAtMillis = 0
)
assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode()))
val pathId = metadata.toPathId(nodeKey)
assertEquals(236, pathId.size())
assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash))
}

@Test
fun `decode invalid path_id`() {
val nodeKey = randomKey()
Expand Down
Loading

0 comments on commit 25ab7ed

Please sign in to comment.