Skip to content

Commit dedd958

Browse files
committed
Add support for Bolt 12 contacts
Add support for contacts as specified in bLIP 42. Contacts are mutually authenticated using a 32-bytes random secret generated when first adding a node to our contacts. When paying contacts, we include our own payment information to allow them to pay us back and us to their contacts. The benefit of this design is that offers stay private by default (they don't include any contact information). It's only when we pay someone we trust that we reveal contact information (which they are free to ignore). The drawback of this design is that if when both nodes independently add each other to their contacts list, they generate a different contact secret: users must manually associate incoming payments to an existing contact to correctly identify incoming payments (by storing multiple secrets for such contacts). This also happens when contacts use multiple wallets, which will all use different contact secrets. I think this is an acceptable trade-off to preserve privacy by default. More details in the bLIP: lightning/blips#42
1 parent f86fffe commit dedd958

File tree

10 files changed

+452
-40
lines changed

10 files changed

+452
-40
lines changed

src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ data class NodeParams(
261261
* This offer will stay valid after restoring the seed on a different device.
262262
* @return the default offer and the private key that will sign invoices for this offer.
263263
*/
264-
fun defaultOffer(trampolineNodeId: PublicKey): Pair<OfferTypes.Offer, PrivateKey> {
264+
fun defaultOffer(trampolineNodeId: PublicKey): OfferTypes.OfferAndKey {
265265
// We generate a deterministic blindingSecret based on:
266266
// - a custom tag indicating that this is used in the Bolt 12 context
267267
// - our trampoline node, which is used as an introduction node for the offer's blinded path

src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt

+7-4
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ data class PayInvoice(override val paymentId: UUID, override val amount: MilliSa
129129
val paymentHash: ByteVector32 = paymentDetails.paymentHash
130130
val recipient: PublicKey = paymentDetails.paymentRequest.nodeId
131131
}
132-
data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List<TrampolineFees>? = null) : SendPayment()
132+
data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val contactSecret: ByteVector32?, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List<TrampolineFees>? = null) : SendPayment()
133133
// @formatter:on
134134

135135
data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand()
@@ -705,7 +705,10 @@ class Peer(
705705
return res.await()
706706
}
707707

708-
suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, fetchInvoiceTimeout: Duration): SendPaymentResult {
708+
/**
709+
* @param contactSecret should only be provided if we'd like to reveal our identity to our contact.
710+
*/
711+
suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, contactSecret: ByteVector32?, fetchInvoiceTimeout: Duration): SendPaymentResult {
709712
val res = CompletableDeferred<SendPaymentResult>()
710713
val paymentId = UUID.randomUUID()
711714
this.launch {
@@ -715,7 +718,7 @@ class Peer(
715718
.first()
716719
)
717720
}
718-
send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeout))
721+
send(PayOffer(paymentId, payerKey, payerNote, amount, offer, contactSecret, fetchInvoiceTimeout))
719722
return res.await()
720723
}
721724

@@ -766,7 +769,7 @@ class Peer(
766769
.first()
767770
.let { event -> replyTo.complete(event.address) }
768771
}
769-
peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).first, languageSubtag))
772+
peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, languageSubtag))
770773
return replyTo.await()
771774
}
772775

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package fr.acinq.lightning.payment
2+
3+
import fr.acinq.bitcoin.ByteVector32
4+
import fr.acinq.bitcoin.Crypto
5+
import fr.acinq.bitcoin.byteVector32
6+
import fr.acinq.lightning.wire.OfferTypes
7+
import io.ktor.utils.io.core.*
8+
9+
/**
10+
* BIP 353 human-readable address of a contact.
11+
*/
12+
data class ContactAddress(val name: String, val domain: String) {
13+
init {
14+
require(name.length < 256) { "bip353 name must be smaller than 256 characters" }
15+
require(domain.length < 256) { "bip353 domain must be smaller than 256 characters" }
16+
}
17+
18+
override fun toString(): String = "$name@$domain"
19+
20+
companion object {
21+
fun fromString(address: String): ContactAddress? {
22+
val parts = address.replace("", "").split('@')
23+
return when {
24+
parts.size != 2 -> null
25+
parts.any { it.length > 255 } -> null
26+
else -> ContactAddress(parts.first(), parts.last())
27+
}
28+
}
29+
}
30+
}
31+
32+
/**
33+
* Contact secrets are used to mutually authenticate payments.
34+
*
35+
* The first node to add the other to its contacts list will generate the [primarySecret] and send it when paying.
36+
* If the second node adds the first node to its contacts list from the received payment, it will use the same
37+
* [primarySecret] and both nodes are able to identify payments from each other.
38+
*
39+
* But if the second node independently added the first node to its contacts list, it may have generated a
40+
* different [primarySecret]. Each node has a different [primarySecret], but they will store the other node's
41+
* [primarySecret] in their [additionalRemoteSecrets], which lets them correctly identify payments.
42+
*
43+
* When sending a payment, we must always send the [primarySecret].
44+
* When receiving payments, we must check if the received contact_secret matches either the [primarySecret]
45+
* or any of the [additionalRemoteSecrets].
46+
*/
47+
data class ContactSecrets(val primarySecret: ByteVector32, val additionalRemoteSecrets: Set<ByteVector32>) {
48+
/**
49+
* This function should be used when we attribute an incoming payment to an existing contact.
50+
* This can be necessary when:
51+
* - our contact added us without using the contact_secret we initially sent them
52+
* - our contact is using a different wallet from the one(s) we have already stored
53+
*/
54+
fun addRemoteSecret(remoteSecret: ByteVector32): ContactSecrets {
55+
return this.copy(additionalRemoteSecrets = additionalRemoteSecrets + remoteSecret)
56+
}
57+
}
58+
59+
/**
60+
* Contacts are trusted people to which we may want to reveal our identity when paying them.
61+
* We're also able to figure out when incoming payments have been made by one of our contacts.
62+
* See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
63+
*/
64+
object Contacts {
65+
66+
/**
67+
* We derive our contact secret deterministically based on our offer and our contact's offer.
68+
* This provides a few interesting properties:
69+
* - if we remove a contact and re-add it using the same offer, we will generate the same contact secret
70+
* - if our contact is using the same deterministic algorithm with a single static offer, they will also generate the same contact secret
71+
*
72+
* Note that this function must only be used when adding a contact that hasn't paid us before.
73+
* If we're adding a contact that paid us before, we must use the contact_secret they sent us,
74+
* which ensures that when we pay them, they'll be able to know it was coming from us (see
75+
* [fromRemoteSecret]).
76+
*/
77+
fun computeContactSecret(ourOffer: OfferTypes.OfferAndKey, theirOffer: OfferTypes.Offer): ContactSecrets {
78+
// If their offer doesn't contain an issuerId, it must contain blinded paths.
79+
val offerNodeId = theirOffer.issuerId ?: theirOffer.paths?.first()?.nodeId!!
80+
val ecdh = offerNodeId.times(ourOffer.privateKey)
81+
val primarySecret = Crypto.sha256("blip42_contact_secret".toByteArray() + ecdh.value.toByteArray()).byteVector32()
82+
return ContactSecrets(primarySecret, setOf())
83+
}
84+
85+
/**
86+
* When adding a contact from which we've received a payment, we must use the contact_secret
87+
* they sent us: this ensures that they'll be able to identify payments coming from us.
88+
*/
89+
fun fromRemoteSecret(remoteSecret: ByteVector32): ContactSecrets = ContactSecrets(remoteSecret, setOf())
90+
91+
}

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
499499
}
500500
is PaymentOnion.FinalPayload.Blinded -> {
501501
// We encrypted the payment metadata for ourselves in the blinded path we included in the invoice.
502-
return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodeId, finalPayload.pathId)) {
502+
return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodePrivateKey, finalPayload.pathId, paymentPart.paymentHash)) {
503503
null -> {
504504
logger.warning { "invalid path_id: ${finalPayload.pathId.toHex()}" }
505505
Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))

src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt

+29-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
4747
private val localOffers: HashMap<ByteVector32, OfferTypes.Offer> = HashMap()
4848

4949
init {
50-
registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).first, null)
50+
registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, null)
5151
}
5252

5353
fun registerOffer(offer: OfferTypes.Offer, pathId: ByteVector32?) {
@@ -58,7 +58,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
5858
* @return invoice requests that must be sent and the corresponding path_id that must be used in case of a timeout.
5959
*/
6060
fun requestInvoice(payOffer: PayOffer): Triple<ByteVector32, List<OnionMessage>, OfferTypes.InvoiceRequest> {
61-
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash)
61+
// If we're providing our contact secret, it means we're willing to reveal our identity to the recipient.
62+
// We include our own offer to allow them to add us to their contacts list and pay us back.
63+
val contactTlvs = setOfNotNull(
64+
payOffer.contactSecret?.let { OfferTypes.InvoiceRequestContactSecret(it) },
65+
payOffer.contactSecret?.let { localOffers[ByteVector32.Zeroes] }?.let { OfferTypes.InvoiceRequestPayerOffer(it) },
66+
)
67+
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash, contactTlvs)
6268
val replyPathId = randomBytes32()
6369
pendingInvoiceRequests[replyPathId] = PendingInvoiceRequest(payOffer, request)
6470
// We add dummy hops to the reply path: this way the receiver only learns that we're at most 3 hops away from our peer.
@@ -162,7 +168,27 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
162168
it.take(63) + ""
163169
}
164170
}
165-
val pathId = OfferPaymentMetadata.V1(ByteVector32(decrypted.pathId), amount, preimage, request.payerId, truncatedPayerNote, request.quantity, currentTimestampMillis()).toPathId(nodeParams.nodePrivateKey)
171+
// We mustn't use too much space in the path_id, otherwise the sender won't be able to include it in its payment onion.
172+
// If the payer_address is provided, we don't include the payer_offer: we can retrieve it from the DNS.
173+
// Otherwise, we want to include the payer_offer, but we must skip it if it's too large.
174+
val payerOfferSize = request.payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records).size }
175+
val payerOffer = when {
176+
request.payerAddress != null -> null
177+
payerOfferSize != null && payerOfferSize > 300 -> null
178+
else -> request.payerOffer
179+
}
180+
val pathId = OfferPaymentMetadata.V2(
181+
ByteVector32(decrypted.pathId),
182+
amount,
183+
preimage,
184+
request.payerId,
185+
truncatedPayerNote,
186+
request.quantity,
187+
request.contactSecret,
188+
payerOffer,
189+
request.payerAddress,
190+
currentTimestampMillis()
191+
).toPathId(nodeParams.nodePrivateKey)
166192
val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId))).write().toByteVector()
167193
val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta
168194
val paymentInfo = OfferTypes.PaymentInfo(

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

+101-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import fr.acinq.bitcoin.io.ByteArrayOutput
66
import fr.acinq.bitcoin.io.Input
77
import fr.acinq.bitcoin.io.Output
88
import fr.acinq.lightning.MilliSatoshi
9+
import fr.acinq.lightning.crypto.ChaCha20Poly1305
910
import fr.acinq.lightning.utils.msat
1011
import fr.acinq.lightning.wire.LightningCodecs
12+
import fr.acinq.lightning.wire.OfferTypes
1113

1214
/**
1315
* The flow for Bolt 12 offer payments is the following:
@@ -37,6 +39,7 @@ sealed class OfferPaymentMetadata {
3739
LightningCodecs.writeByte(this.version.toInt(), out)
3840
when (this) {
3941
is V1 -> this.write(out)
42+
is V2 -> this.write(out)
4043
}
4144
return out.toByteArray().byteVector()
4245
}
@@ -48,6 +51,25 @@ sealed class OfferPaymentMetadata {
4851
val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey)
4952
encoded + signature
5053
}
54+
is V2 -> {
55+
// We only encrypt what comes after the version byte.
56+
val encoded = run {
57+
val out = ByteArrayOutput()
58+
this.write(out)
59+
out.toByteArray()
60+
}
61+
val (encrypted, mac) = run {
62+
val paymentHash = Crypto.sha256(this.preimage).byteVector32()
63+
val priv = V2.deriveKey(nodeKey, paymentHash)
64+
val nonce = paymentHash.take(12).toByteArray()
65+
ChaCha20Poly1305.encrypt(priv.value.toByteArray(), nonce, encoded, paymentHash.toByteArray())
66+
}
67+
val out = ByteArrayOutput()
68+
out.write(2) // version
69+
out.write(encrypted)
70+
out.write(mac)
71+
out.toByteArray().byteVector()
72+
}
5173
}
5274

5375
/** In this first version, we simply sign the payment metadata to verify its authenticity when receiving the payment. */
@@ -86,6 +108,69 @@ sealed class OfferPaymentMetadata {
86108
}
87109
}
88110

111+
/** In this version, we encrypt the payment metadata with a key derived from our seed. */
112+
data class V2(
113+
override val offerId: ByteVector32,
114+
override val amount: MilliSatoshi,
115+
override val preimage: ByteVector32,
116+
val payerKey: PublicKey,
117+
val payerNote: String?,
118+
val quantity: Long,
119+
val contactSecret: ByteVector32?,
120+
val payerOffer: OfferTypes.Offer?,
121+
val payerAddress: ContactAddress?,
122+
override val createdAtMillis: Long
123+
) : OfferPaymentMetadata() {
124+
override val version: Byte get() = 2
125+
126+
private fun writeOptionalBytes(data: ByteArray?, out: Output) = when (data) {
127+
null -> LightningCodecs.writeU16(0, out)
128+
else -> {
129+
LightningCodecs.writeU16(data.size, out)
130+
LightningCodecs.writeBytes(data, out)
131+
}
132+
}
133+
134+
fun write(out: Output) {
135+
LightningCodecs.writeBytes(offerId, out)
136+
LightningCodecs.writeU64(amount.toLong(), out)
137+
LightningCodecs.writeBytes(preimage, out)
138+
LightningCodecs.writeBytes(payerKey.value, out)
139+
writeOptionalBytes(payerNote?.encodeToByteArray(), out)
140+
LightningCodecs.writeU64(quantity, out)
141+
writeOptionalBytes(contactSecret?.toByteArray(), out)
142+
writeOptionalBytes(payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records) }, out)
143+
writeOptionalBytes(payerAddress?.toString()?.encodeToByteArray(), out)
144+
LightningCodecs.writeU64(createdAtMillis, out)
145+
}
146+
147+
companion object {
148+
private fun readOptionalBytes(input: Input): ByteArray? = when (val size = LightningCodecs.u16(input)) {
149+
0 -> null
150+
else -> LightningCodecs.bytes(input, size)
151+
}
152+
153+
fun read(input: Input): V2 {
154+
val offerId = LightningCodecs.bytes(input, 32).byteVector32()
155+
val amount = LightningCodecs.u64(input).msat
156+
val preimage = LightningCodecs.bytes(input, 32).byteVector32()
157+
val payerKey = PublicKey(LightningCodecs.bytes(input, 33))
158+
val payerNote = readOptionalBytes(input)?.decodeToString()
159+
val quantity = LightningCodecs.u64(input)
160+
val contactSecret = readOptionalBytes(input)?.byteVector32()
161+
val payerOffer = readOptionalBytes(input)?.let { OfferTypes.Offer.tlvSerializer.read(it) }?.let { OfferTypes.Offer(it) }
162+
val payerAddress = readOptionalBytes(input)?.decodeToString()?.let { ContactAddress.fromString(it) }
163+
val createdAtMillis = LightningCodecs.u64(input)
164+
return V2(offerId, amount, preimage, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress, createdAtMillis)
165+
}
166+
167+
fun deriveKey(nodeKey: PrivateKey, paymentHash: ByteVector32): PrivateKey {
168+
val tweak = Crypto.sha256("offer_payment_metadata_v2".encodeToByteArray() + paymentHash.toByteArray() + nodeKey.value.toByteArray())
169+
return nodeKey * PrivateKey(tweak)
170+
}
171+
}
172+
}
173+
89174
companion object {
90175
/**
91176
* Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB).
@@ -95,6 +180,7 @@ sealed class OfferPaymentMetadata {
95180
val input = ByteArrayInput(encoded.toByteArray())
96181
return when (val version = LightningCodecs.byte(input)) {
97182
1 -> V1.read(input)
183+
2 -> V2.read(input)
98184
else -> throw IllegalArgumentException("unknown offer payment metadata version: $version")
99185
}
100186
}
@@ -103,7 +189,7 @@ sealed class OfferPaymentMetadata {
103189
* Decode an [OfferPaymentMetadata] stored in a blinded path's path_id field.
104190
* @return null if the path_id doesn't contain valid data created by us.
105191
*/
106-
fun fromPathId(nodeId: PublicKey, pathId: ByteVector): OfferPaymentMetadata? {
192+
fun fromPathId(nodeKey: PrivateKey, pathId: ByteVector, paymentHash: ByteVector32): OfferPaymentMetadata? {
107193
if (pathId.isEmpty()) return null
108194
val input = ByteArrayInput(pathId.toByteArray())
109195
when (LightningCodecs.byte(input)) {
@@ -113,10 +199,23 @@ sealed class OfferPaymentMetadata {
113199
val metadata = LightningCodecs.bytes(input, metadataSize)
114200
val signature = LightningCodecs.bytes(input, 64).byteVector64()
115201
// Note that the signature includes the version byte.
116-
if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeId)) return null
202+
if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeKey.publicKey())) return null
117203
// This call is safe since we verified that we have the right number of bytes and the signature was valid.
118204
return V1.read(ByteArrayInput(metadata))
119205
}
206+
2 -> {
207+
val priv = V2.deriveKey(nodeKey, paymentHash)
208+
val nonce = paymentHash.take(12).toByteArray()
209+
val encryptedSize = input.availableBytes - 16
210+
return try {
211+
val encrypted = LightningCodecs.bytes(input, encryptedSize)
212+
val mac = LightningCodecs.bytes(input, 16)
213+
val decrypted = ChaCha20Poly1305.decrypt(priv.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac)
214+
V2.read(ByteArrayInput(decrypted))
215+
} catch (_: Throwable) {
216+
null
217+
}
218+
}
120219
else -> return null
121220
}
122221
}

0 commit comments

Comments
 (0)