Skip to content

Commit

Permalink
Add support for Bolt 12 contacts
Browse files Browse the repository at this point in the history
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
  • Loading branch information
t-bast committed Oct 17, 2024
1 parent f86fffe commit dedd958
Show file tree
Hide file tree
Showing 10 changed files with 452 additions and 40 deletions.
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ data class NodeParams(
* This offer will stay valid after restoring the seed on a different device.
* @return the default offer and the private key that will sign invoices for this offer.
*/
fun defaultOffer(trampolineNodeId: PublicKey): Pair<OfferTypes.Offer, PrivateKey> {
fun defaultOffer(trampolineNodeId: PublicKey): OfferTypes.OfferAndKey {
// We generate a deterministic blindingSecret based on:
// - a custom tag indicating that this is used in the Bolt 12 context
// - our trampoline node, which is used as an introduction node for the offer's blinded path
Expand Down
11 changes: 7 additions & 4 deletions src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ data class PayInvoice(override val paymentId: UUID, override val amount: MilliSa
val paymentHash: ByteVector32 = paymentDetails.paymentHash
val recipient: PublicKey = paymentDetails.paymentRequest.nodeId
}
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()
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()
// @formatter:on

data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand()
Expand Down Expand Up @@ -705,7 +705,10 @@ class Peer(
return res.await()
}

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

Expand Down Expand Up @@ -766,7 +769,7 @@ class Peer(
.first()
.let { event -> replyTo.complete(event.address) }
}
peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).first, languageSubtag))
peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, languageSubtag))
return replyTo.await()
}

Expand Down
91 changes: 91 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/payment/Contacts.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package fr.acinq.lightning.payment

import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.core.*

/**
* BIP 353 human-readable address of a contact.
*/
data class ContactAddress(val name: String, val domain: String) {
init {
require(name.length < 256) { "bip353 name must be smaller than 256 characters" }
require(domain.length < 256) { "bip353 domain must be smaller than 256 characters" }
}

override fun toString(): String = "$name@$domain"

companion object {
fun fromString(address: String): ContactAddress? {
val parts = address.replace("", "").split('@')
return when {
parts.size != 2 -> null
parts.any { it.length > 255 } -> null
else -> ContactAddress(parts.first(), parts.last())
}
}
}
}

/**
* Contact secrets are used to mutually authenticate payments.
*
* The first node to add the other to its contacts list will generate the [primarySecret] and send it when paying.
* If the second node adds the first node to its contacts list from the received payment, it will use the same
* [primarySecret] and both nodes are able to identify payments from each other.
*
* But if the second node independently added the first node to its contacts list, it may have generated a
* different [primarySecret]. Each node has a different [primarySecret], but they will store the other node's
* [primarySecret] in their [additionalRemoteSecrets], which lets them correctly identify payments.
*
* When sending a payment, we must always send the [primarySecret].
* When receiving payments, we must check if the received contact_secret matches either the [primarySecret]
* or any of the [additionalRemoteSecrets].
*/
data class ContactSecrets(val primarySecret: ByteVector32, val additionalRemoteSecrets: Set<ByteVector32>) {
/**
* This function should be used when we attribute an incoming payment to an existing contact.
* This can be necessary when:
* - our contact added us without using the contact_secret we initially sent them
* - our contact is using a different wallet from the one(s) we have already stored
*/
fun addRemoteSecret(remoteSecret: ByteVector32): ContactSecrets {
return this.copy(additionalRemoteSecrets = additionalRemoteSecrets + remoteSecret)
}
}

/**
* Contacts are trusted people to which we may want to reveal our identity when paying them.
* We're also able to figure out when incoming payments have been made by one of our contacts.
* See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
*/
object Contacts {

/**
* We derive our contact secret deterministically based on our offer and our contact's offer.
* This provides a few interesting properties:
* - if we remove a contact and re-add it using the same offer, we will generate the same contact secret
* - if our contact is using the same deterministic algorithm with a single static offer, they will also generate the same contact secret
*
* Note that this function must only be used when adding a contact that hasn't paid us before.
* If we're adding a contact that paid us before, we must use the contact_secret they sent us,
* which ensures that when we pay them, they'll be able to know it was coming from us (see
* [fromRemoteSecret]).
*/
fun computeContactSecret(ourOffer: OfferTypes.OfferAndKey, theirOffer: OfferTypes.Offer): ContactSecrets {
// If their offer doesn't contain an issuerId, it must contain blinded paths.
val offerNodeId = theirOffer.issuerId ?: theirOffer.paths?.first()?.nodeId!!
val ecdh = offerNodeId.times(ourOffer.privateKey)
val primarySecret = Crypto.sha256("blip42_contact_secret".toByteArray() + ecdh.value.toByteArray()).byteVector32()
return ContactSecrets(primarySecret, setOf())
}

/**
* When adding a contact from which we've received a payment, we must use the contact_secret
* they sent us: this ensures that they'll be able to identify payments coming from us.
*/
fun fromRemoteSecret(remoteSecret: ByteVector32): ContactSecrets = ContactSecrets(remoteSecret, setOf())

}
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
}
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)) {
return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodePrivateKey, finalPayload.pathId, paymentPart.paymentHash)) {
null -> {
logger.warning { "invalid path_id: ${finalPayload.pathId.toHex()}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))
Expand Down
32 changes: 29 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
private val localOffers: HashMap<ByteVector32, OfferTypes.Offer> = HashMap()

init {
registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).first, null)
registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, null)
}

fun registerOffer(offer: OfferTypes.Offer, pathId: ByteVector32?) {
Expand All @@ -58,7 +58,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
* @return invoice requests that must be sent and the corresponding path_id that must be used in case of a timeout.
*/
fun requestInvoice(payOffer: PayOffer): Triple<ByteVector32, List<OnionMessage>, OfferTypes.InvoiceRequest> {
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash)
// If we're providing our contact secret, it means we're willing to reveal our identity to the recipient.
// We include our own offer to allow them to add us to their contacts list and pay us back.
val contactTlvs = setOfNotNull(
payOffer.contactSecret?.let { OfferTypes.InvoiceRequestContactSecret(it) },
payOffer.contactSecret?.let { localOffers[ByteVector32.Zeroes] }?.let { OfferTypes.InvoiceRequestPayerOffer(it) },
)
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash, contactTlvs)
val replyPathId = randomBytes32()
pendingInvoiceRequests[replyPathId] = PendingInvoiceRequest(payOffer, request)
// 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.
Expand Down Expand Up @@ -162,7 +168,27 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
it.take(63) + ""
}
}
val pathId = OfferPaymentMetadata.V1(ByteVector32(decrypted.pathId), amount, preimage, request.payerId, truncatedPayerNote, request.quantity, currentTimestampMillis()).toPathId(nodeParams.nodePrivateKey)
// 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.
// If the payer_address is provided, we don't include the payer_offer: we can retrieve it from the DNS.
// Otherwise, we want to include the payer_offer, but we must skip it if it's too large.
val payerOfferSize = request.payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records).size }
val payerOffer = when {
request.payerAddress != null -> null
payerOfferSize != null && payerOfferSize > 300 -> null
else -> request.payerOffer
}
val pathId = OfferPaymentMetadata.V2(
ByteVector32(decrypted.pathId),
amount,
preimage,
request.payerId,
truncatedPayerNote,
request.quantity,
request.contactSecret,
payerOffer,
request.payerAddress,
currentTimestampMillis()
).toPathId(nodeParams.nodePrivateKey)
val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId))).write().toByteVector()
val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta
val paymentInfo = OfferTypes.PaymentInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ 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.crypto.ChaCha20Poly1305
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.wire.LightningCodecs
import fr.acinq.lightning.wire.OfferTypes

/**
* The flow for Bolt 12 offer payments is the following:
Expand Down Expand Up @@ -37,6 +39,7 @@ sealed class OfferPaymentMetadata {
LightningCodecs.writeByte(this.version.toInt(), out)
when (this) {
is V1 -> this.write(out)
is V2 -> this.write(out)
}
return out.toByteArray().byteVector()
}
Expand All @@ -48,6 +51,25 @@ sealed class OfferPaymentMetadata {
val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey)
encoded + signature
}
is V2 -> {
// We only encrypt what comes after the version byte.
val encoded = run {
val out = ByteArrayOutput()
this.write(out)
out.toByteArray()
}
val (encrypted, mac) = run {
val paymentHash = Crypto.sha256(this.preimage).byteVector32()
val priv = V2.deriveKey(nodeKey, paymentHash)
val nonce = paymentHash.take(12).toByteArray()
ChaCha20Poly1305.encrypt(priv.value.toByteArray(), nonce, encoded, paymentHash.toByteArray())
}
val out = ByteArrayOutput()
out.write(2) // version
out.write(encrypted)
out.write(mac)
out.toByteArray().byteVector()
}
}

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

/** In this version, we encrypt the payment metadata with a key derived from our seed. */
data class V2(
override val offerId: ByteVector32,
override val amount: MilliSatoshi,
override val preimage: ByteVector32,
val payerKey: PublicKey,
val payerNote: String?,
val quantity: Long,
val contactSecret: ByteVector32?,
val payerOffer: OfferTypes.Offer?,
val payerAddress: ContactAddress?,
override val createdAtMillis: Long
) : OfferPaymentMetadata() {
override val version: Byte get() = 2

private fun writeOptionalBytes(data: ByteArray?, out: Output) = when (data) {
null -> LightningCodecs.writeU16(0, out)
else -> {
LightningCodecs.writeU16(data.size, out)
LightningCodecs.writeBytes(data, out)
}
}

fun write(out: Output) {
LightningCodecs.writeBytes(offerId, out)
LightningCodecs.writeU64(amount.toLong(), out)
LightningCodecs.writeBytes(preimage, out)
LightningCodecs.writeBytes(payerKey.value, out)
writeOptionalBytes(payerNote?.encodeToByteArray(), out)
LightningCodecs.writeU64(quantity, out)
writeOptionalBytes(contactSecret?.toByteArray(), out)
writeOptionalBytes(payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records) }, out)
writeOptionalBytes(payerAddress?.toString()?.encodeToByteArray(), out)
LightningCodecs.writeU64(createdAtMillis, out)
}

companion object {
private fun readOptionalBytes(input: Input): ByteArray? = when (val size = LightningCodecs.u16(input)) {
0 -> null
else -> LightningCodecs.bytes(input, size)
}

fun read(input: Input): V2 {
val offerId = LightningCodecs.bytes(input, 32).byteVector32()
val amount = LightningCodecs.u64(input).msat
val preimage = LightningCodecs.bytes(input, 32).byteVector32()
val payerKey = PublicKey(LightningCodecs.bytes(input, 33))
val payerNote = readOptionalBytes(input)?.decodeToString()
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 createdAtMillis = LightningCodecs.u64(input)
return V2(offerId, amount, preimage, payerKey, payerNote, quantity, contactSecret, payerOffer, payerAddress, createdAtMillis)
}

fun deriveKey(nodeKey: PrivateKey, paymentHash: ByteVector32): PrivateKey {
val tweak = Crypto.sha256("offer_payment_metadata_v2".encodeToByteArray() + paymentHash.toByteArray() + nodeKey.value.toByteArray())
return nodeKey * PrivateKey(tweak)
}
}
}

companion object {
/**
* Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB).
Expand All @@ -95,6 +180,7 @@ sealed class OfferPaymentMetadata {
val input = ByteArrayInput(encoded.toByteArray())
return when (val version = LightningCodecs.byte(input)) {
1 -> V1.read(input)
2 -> V2.read(input)
else -> throw IllegalArgumentException("unknown offer payment metadata version: $version")
}
}
Expand All @@ -103,7 +189,7 @@ sealed class OfferPaymentMetadata {
* 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? {
fun fromPathId(nodeKey: PrivateKey, pathId: ByteVector, paymentHash: ByteVector32): OfferPaymentMetadata? {
if (pathId.isEmpty()) return null
val input = ByteArrayInput(pathId.toByteArray())
when (LightningCodecs.byte(input)) {
Expand All @@ -113,10 +199,23 @@ sealed class OfferPaymentMetadata {
val metadata = LightningCodecs.bytes(input, metadataSize)
val signature = LightningCodecs.bytes(input, 64).byteVector64()
// Note that the signature includes the version byte.
if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeId)) return null
if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeKey.publicKey())) 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))
}
2 -> {
val priv = V2.deriveKey(nodeKey, paymentHash)
val nonce = paymentHash.take(12).toByteArray()
val encryptedSize = input.availableBytes - 16
return try {
val encrypted = LightningCodecs.bytes(input, encryptedSize)
val mac = LightningCodecs.bytes(input, 16)
val decrypted = ChaCha20Poly1305.decrypt(priv.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac)
V2.read(ByteArrayInput(decrypted))
} catch (_: Throwable) {
null
}
}
else -> return null
}
}
Expand Down
Loading

0 comments on commit dedd958

Please sign in to comment.