Skip to content

Commit 25ab7ed

Browse files
committed
Add signature when including invreq_bip_353_name
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.
1 parent a907cde commit 25ab7ed

File tree

6 files changed

+207
-26
lines changed

6 files changed

+207
-26
lines changed

modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package fr.acinq.lightning.payment
22

33
import fr.acinq.bitcoin.ByteVector32
44
import fr.acinq.bitcoin.Crypto
5+
import fr.acinq.bitcoin.PublicKey
56
import fr.acinq.bitcoin.byteVector32
67
import fr.acinq.lightning.wire.OfferTypes
78
import io.ktor.utils.io.core.*
@@ -29,6 +30,26 @@ data class ContactAddress(val name: String, val domain: String) {
2930
}
3031
}
3132

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

modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ sealed class OfferPaymentMetadata {
118118
val quantity: Long,
119119
val contactSecret: ByteVector32?,
120120
val payerOffer: OfferTypes.Offer?,
121-
val payerAddress: ContactAddress?,
121+
val payerAddress: UnverifiedContactAddress?,
122122
override val createdAtMillis: Long
123123
) : OfferPaymentMetadata() {
124124
override val version: Byte get() = 2
@@ -131,6 +131,16 @@ sealed class OfferPaymentMetadata {
131131
}
132132
}
133133

134+
private fun writeOptionalContactAddress(payerAddress: UnverifiedContactAddress?, out: Output) = when (payerAddress) {
135+
null -> LightningCodecs.writeU16(0, out)
136+
else -> {
137+
val address = payerAddress.address.toString().encodeToByteArray()
138+
LightningCodecs.writeU16(address.size + 33, out)
139+
LightningCodecs.writeBytes(address, out)
140+
LightningCodecs.writeBytes(payerAddress.expectedOfferSigningKey.value, out)
141+
}
142+
}
143+
134144
fun write(out: Output) {
135145
LightningCodecs.writeBytes(offerId, out)
136146
LightningCodecs.writeU64(amount.toLong(), out)
@@ -140,7 +150,7 @@ sealed class OfferPaymentMetadata {
140150
LightningCodecs.writeU64(quantity, out)
141151
writeOptionalBytes(contactSecret?.toByteArray(), out)
142152
writeOptionalBytes(payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records) }, out)
143-
writeOptionalBytes(payerAddress?.toString()?.encodeToByteArray(), out)
153+
writeOptionalContactAddress(payerAddress, out)
144154
LightningCodecs.writeU64(createdAtMillis, out)
145155
}
146156

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

163+
private fun readOptionalContactAddress(input: Input): UnverifiedContactAddress? = when (val size = LightningCodecs.u16(input)) {
164+
0 -> null
165+
else -> ContactAddress.fromString(LightningCodecs.bytes(input, size - 33).decodeToString())?.let { address ->
166+
val offerKey = PublicKey(LightningCodecs.bytes(input, 33))
167+
UnverifiedContactAddress(address, offerKey)
168+
}
169+
}
170+
153171
fun read(input: Input): V2 {
154172
val offerId = LightningCodecs.bytes(input, 32).byteVector32()
155173
val amount = LightningCodecs.u64(input).msat
@@ -159,7 +177,7 @@ sealed class OfferPaymentMetadata {
159177
val quantity = LightningCodecs.u64(input)
160178
val contactSecret = readOptionalBytes(input)?.byteVector32()
161179
val payerOffer = readOptionalBytes(input)?.let { OfferTypes.Offer.tlvSerializer.read(it) }?.let { OfferTypes.Offer(it) }
162-
val payerAddress = readOptionalBytes(input)?.decodeToString()?.let { ContactAddress.fromString(it) }
180+
val payerAddress = readOptionalContactAddress(input)
163181
val createdAtMillis = LightningCodecs.u64(input)
164182
return V2(offerId, amount, preimage, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress, createdAtMillis)
165183
}

modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import fr.acinq.lightning.Lightning.randomBytes32
1414
import fr.acinq.lightning.crypto.RouteBlinding
1515
import fr.acinq.lightning.message.OnionMessages
1616
import fr.acinq.lightning.payment.ContactAddress
17+
import fr.acinq.lightning.payment.UnverifiedContactAddress
1718

1819
/**
1920
* Lightning Bolt 12 offers
@@ -460,6 +461,28 @@ object OfferTypes {
460461
}
461462
}
462463

464+
/**
465+
* When [[InvoiceRequestPayerAddress]] is included, the invoice request must be signed with the signing key of the offer matching the BIP 353 address.
466+
* This proves that the payer really owns this BIP 353 address.
467+
* See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
468+
*/
469+
data class InvoiceRequestPayerAddressSignature(val offerSigningKey: PublicKey, val signature: ByteVector64) : InvoiceRequestTlv() {
470+
override val tag: Long get() = InvoiceRequestPayerAddressSignature.tag
471+
override fun write(out: Output) {
472+
LightningCodecs.writeBytes(offerSigningKey.value, out)
473+
LightningCodecs.writeBytes(signature, out)
474+
}
475+
476+
companion object : TlvValueReader<InvoiceRequestPayerAddressSignature> {
477+
const val tag: Long = 2_000_001_735L
478+
override fun read(input: Input): InvoiceRequestPayerAddressSignature {
479+
val offerSigningKey = PublicKey(LightningCodecs.bytes(input, 33))
480+
val signature = LightningCodecs.bytes(input, 64).byteVector64()
481+
return InvoiceRequestPayerAddressSignature(offerSigningKey, signature)
482+
}
483+
}
484+
}
485+
463486
/**
464487
* Payment paths to send the payment to.
465488
*/
@@ -919,7 +942,7 @@ object OfferTypes {
919942
val payerNote: String? = records.get<InvoiceRequestPayerNote>()?.note
920943
val contactSecret: ByteVector32? = records.get<InvoiceRequestContactSecret>()?.contactSecret
921944
val payerOffer: Offer? = records.get<InvoiceRequestPayerOffer>()?.offer
922-
val payerAddress: ContactAddress? = records.get<InvoiceRequestPayerAddress>()?.address
945+
val payerAddress: UnverifiedContactAddress? = records.get<InvoiceRequestPayerAddress>()?.let { pa -> records.get<InvoiceRequestPayerAddressSignature>()?.let { ps -> UnverifiedContactAddress(pa.address, ps.offerSigningKey) } }
923946
private val signature: ByteVector64 = records.get<Signature>()!!.signature
924947

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

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

935-
fun checkSignature(): Boolean =
936-
verifySchnorr(
937-
signatureTag,
938-
rootHash(removeSignature(records)),
939-
signature,
940-
payerId
941-
)
959+
private fun checkPayerAddressSignature(): Boolean = when (val ps = records.get<InvoiceRequestPayerAddressSignature>()) {
960+
null -> true
961+
else -> {
962+
// The payer address signature covers the invoice request without its top-level signature.
963+
// Note that the standard invoice request signature includes the InvoiceRequestPayerAddressSignature field.
964+
val signedTlvs = TlvStream(records.records.filter { it !is Signature && it !is InvoiceRequestPayerAddressSignature }.toSet(), records.unknown)
965+
val signatureTag = ByteVector(("lightning" + "invoice_request" + "invreq_payer_bip_353_signature").encodeToByteArray())
966+
verifySchnorr(signatureTag, rootHash(signedTlvs), ps.signature, ps.offerSigningKey)
967+
}
968+
}
969+
970+
fun checkSignature(): Boolean = verifySchnorr(signatureTag, rootHash(removeSignature(records)), signature, payerId)
942971

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

952981
companion object {
953982
val hrp = "lnr"
954-
val signatureTag: ByteVector =
955-
ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray())
983+
val signatureTag: ByteVector = ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray())
956984

957985
/**
958986
* Create a request to fetch an invoice for a given offer.
@@ -999,9 +1027,10 @@ object OfferTypes {
9991027
is Left -> return Left(offer.value)
10001028
is Right -> {}
10011029
}
1002-
if (records.get<InvoiceRequestMetadata>() == null) return Left(MissingRequiredTlv(0L))
1003-
if (records.get<InvoiceRequestPayerId>() == null) return Left(MissingRequiredTlv(88))
1004-
if (records.get<Signature>() == null) return Left(MissingRequiredTlv(240))
1030+
if (records.get<InvoiceRequestMetadata>() == null) return Left(MissingRequiredTlv(InvoiceRequestMetadata.tag))
1031+
if (records.get<InvoiceRequestAmount>() == null && records.get<OfferAmount>() == null) return Left(MissingRequiredTlv(InvoiceRequestAmount.tag))
1032+
if (records.get<InvoiceRequestPayerId>() == null) return Left(MissingRequiredTlv(InvoiceRequestPayerId.tag))
1033+
if (records.get<Signature>() == null) return Left(MissingRequiredTlv(Signature.tag))
10051034
if (records.unknown.any { !isInvoiceRequestTlv(it) }) return Left(ForbiddenTlv(records.unknown.find { !isInvoiceRequestTlv(it) }!!.tag))
10061035
return Right(InvoiceRequest(records))
10071036
}
@@ -1031,6 +1060,7 @@ object OfferTypes {
10311060
InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader<InvoiceRequestTlv>,
10321061
InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader<InvoiceRequestTlv>,
10331062
InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader<InvoiceRequestTlv>,
1063+
InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader<InvoiceRequestTlv>,
10341064
Signature.tag to Signature as TlvValueReader<InvoiceRequestTlv>,
10351065
)
10361066
)
@@ -1073,6 +1103,7 @@ object OfferTypes {
10731103
InvoiceRequestContactSecret.tag to InvoiceRequestContactSecret as TlvValueReader<InvoiceTlv>,
10741104
InvoiceRequestPayerOffer.tag to InvoiceRequestPayerOffer as TlvValueReader<InvoiceTlv>,
10751105
InvoiceRequestPayerAddress.tag to InvoiceRequestPayerAddress as TlvValueReader<InvoiceTlv>,
1106+
InvoiceRequestPayerAddressSignature.tag to InvoiceRequestPayerAddressSignature as TlvValueReader<InvoiceTlv>,
10761107
// Invoice part
10771108
InvoicePaths.tag to InvoicePaths as TlvValueReader<InvoiceTlv>,
10781109
InvoiceBlindedPay.tag to InvoiceBlindedPay as TlvValueReader<InvoiceTlv>,

modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/ContactsTestsCommon.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import fr.acinq.lightning.wire.OfferTypes
88
import fr.acinq.lightning.wire.TlvStream
99
import kotlin.test.Test
1010
import kotlin.test.assertEquals
11+
import kotlin.test.assertTrue
12+
import kotlin.test.assertFalse
1113

1214
class ContactsTestsCommon : LightningTestSuite() {
1315

@@ -35,6 +37,9 @@ class ContactsTestsCommon : LightningTestSuite() {
3537
assertEquals("810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8", contactSecretAlice.primarySecret.toHex())
3638
val contactSecretBob = Contacts.computeContactSecret(bobOfferAndKey, aliceOfferAndKey.offer)
3739
assertEquals(contactSecretAlice, contactSecretBob)
40+
val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("[email protected]")!!, bobOfferAndKey.privateKey.publicKey())
41+
assertTrue(payerAddress.verify(bobOfferAndKey.offer))
42+
assertFalse(payerAddress.verify(aliceOfferAndKey.offer))
3843
}
3944
run {
4045
// The remote offer contains an issuer_id and a blinded path.
@@ -53,7 +58,8 @@ class ContactsTestsCommon : LightningTestSuite() {
5358
assertEquals("4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c", contactSecretAlice.primarySecret.toHex())
5459
val contactSecretBob = Contacts.computeContactSecret(OfferTypes.OfferAndKey(bobOffer, issuerKey), aliceOfferAndKey.offer)
5560
assertEquals(contactSecretAlice, contactSecretBob)
56-
61+
val payerAddress = UnverifiedContactAddress(ContactAddress.fromString("[email protected]")!!, issuerKey.publicKey())
62+
assertTrue(payerAddress.verify(bobOffer))
5763
}
5864
}
5965

modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class OfferPaymentMetadataTestsCommon {
7777
}
7878

7979
@Test
80-
fun `encode - decode v2 metadata with contact information`() {
80+
fun `encode - decode v2 metadata with contact offer`() {
8181
val nodeKey = randomKey()
8282
val preimage = randomBytes32()
8383
val paymentHash = Crypto.sha256(preimage).byteVector32()
@@ -111,6 +111,29 @@ class OfferPaymentMetadataTestsCommon {
111111
assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash))
112112
}
113113

114+
@Test
115+
fun `encode - decode v2 metadata with contact address`() {
116+
val nodeKey = randomKey()
117+
val preimage = randomBytes32()
118+
val paymentHash = Crypto.sha256(preimage).byteVector32()
119+
val metadata = OfferPaymentMetadata.V2(
120+
offerId = randomBytes32(),
121+
amount = 200_000_000.msat,
122+
preimage = preimage,
123+
payerKey = randomKey().publicKey(),
124+
payerNote = "hello there",
125+
quantity = 1,
126+
contactSecret = randomBytes32(),
127+
payerOffer = null,
128+
payerAddress = UnverifiedContactAddress(ContactAddress.fromString("[email protected]")!!, randomKey().publicKey()),
129+
createdAtMillis = 0
130+
)
131+
assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode()))
132+
val pathId = metadata.toPathId(nodeKey)
133+
assertEquals(236, pathId.size())
134+
assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, paymentHash))
135+
}
136+
114137
@Test
115138
fun `decode invalid path_id`() {
116139
val nodeKey = randomKey()

0 commit comments

Comments
 (0)