Skip to content

Commit 9876677

Browse files
committed
Implement latest changes
The `proof_signature` now signs a merkle tree that contains all TLVs of the payer proof, similar to how `offer`, `invoice_request` and `invoice` are signed.
1 parent d7313b6 commit 9876677

5 files changed

Lines changed: 414 additions & 208 deletions

File tree

eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/PayerProof.scala

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ case class PayerProof(records: TlvStream[PayerProofTlv]) {
4141
PayerProof.validate(records) match {
4242
case Left(_) => false
4343
case Right(_) =>
44+
// The proof signature must be valid: it signs all TLVs (except itself) using the invreq_payer_id,
45+
// with an empty invreq_metadata field (to prevent brute-forcing omitted fields).
46+
val payerSig = records.get[ProofSignature].get.signature
47+
val payerId = records.get[InvoiceRequestPayerId].get.publicKey
48+
val proofRootHash = rootHash(TlvStream(records.records.filterNot(_.isInstanceOf[ProofSignature]), records.unknown), OfferCodecs.payerProofCodec)
49+
if (!verifySchnorr(PayerProof.signatureTag, proofRootHash, payerSig, payerId)) {
50+
return false
51+
}
52+
// The proof must contain enough data to reconstruct the invoice merkle root.
4453
val leafNonces = records.get[LeafHashes].map(_.hashes).getOrElse(Nil)
4554
val markers = records.get[OmittedTlvs].map(_.missing).getOrElse(Nil)
4655
val missingHashes = records.get[MissingHashes].map(_.missing).getOrElse(Nil)
@@ -66,7 +75,10 @@ case class PayerProof(records: TlvStream[PayerProofTlv]) {
6675
val omittedLeavesOk = allLeavesExceptMetadata.zipWithIndex.forall {
6776
case ((_, Some(_)), _) => true
6877
case ((marker, None), idx) if idx == 0 => marker == UInt64(1)
69-
case ((marker, None), idx) => marker == (allLeavesExceptMetadata(idx - 1)._1 + 1)
78+
case ((marker, None), idx) =>
79+
val previous = allLeavesExceptMetadata(idx - 1)._1
80+
val expected = if (previous == UInt64(239)) UInt64(1_000_000_000) else previous + 1
81+
marker == expected
7082
}
7183
if (!omittedLeavesOk) {
7284
return false
@@ -75,22 +87,11 @@ case class PayerProof(records: TlvStream[PayerProofTlv]) {
7587
val allLeaves = (Option.empty[LeafWithNonce] +: allLeavesExceptMetadata.map(_._2)).toIndexedSeq
7688
PayerProofTree.merkleRoot(allLeaves, missingHashes) match {
7789
case Left(_) => false
78-
case Right(rootHash) =>
90+
case Right(invoiceRootHash) =>
91+
// The invoice signature must be valid against invoice_node_id over the reconstructed merkle root.
7992
val invoiceSig = records.get[Signature].get.signature
8093
val invoiceNodeId = records.get[InvoiceNodeId].get.nodeId
81-
if (!verifySchnorr(Bolt12Invoice.signatureTag, rootHash, invoiceSig, invoiceNodeId)) {
82-
// The invoice signature must be valid against invoice_node_id over the reconstructed merkle root.
83-
false
84-
} else {
85-
// The payer signature must be valid against invreq_payer_id over SHA256(note || merkle_root).
86-
val payerSig = records.get[PayerSignature].get.signature
87-
val payerId = records.get[InvoiceRequestPayerId].get.publicKey
88-
val msg = records.get[PayerSignature].flatMap(_.note_opt) match {
89-
case Some(note) => Crypto.sha256(ByteVector(note.getBytes) ++ rootHash)
90-
case None => Crypto.sha256(rootHash)
91-
}
92-
verifySchnorr(PayerProof.signatureTag, msg, payerSig, payerId)
93-
}
94+
verifySchnorr(Bolt12Invoice.signatureTag, invoiceRootHash, invoiceSig, invoiceNodeId)
9495
}
9596
}
9697
}
@@ -105,7 +106,7 @@ case class PayerProof(records: TlvStream[PayerProofTlv]) {
105106
object PayerProof {
106107

107108
val hrp = "lnp"
108-
val signatureTag: ByteVector = ByteVector(("lightning" + "payer_proof" + "payer_signature").getBytes)
109+
val signatureTag: ByteVector = ByteVector(("lightning" + "payer_proof" + "proof_signature").getBytes)
109110

110111
/** Specifies which optional fields from the invoice should be included in the payer proof. */
111112
case class IncludedFields(offerChains: Boolean = false,
@@ -134,6 +135,8 @@ object PayerProof {
134135
unknown: Set[UInt64] = Set.empty)
135136

136137
def create(invoice: Bolt12Invoice, preimage: ByteVector32, payerKey: PrivateKey, fields: IncludedFields, note_opt: Option[String]): PayerProof = {
138+
// Valid Bolt12 invoices always contain the invreq_metadata field: it is used as a hashing nonce to prevent brute-forcing private fields.
139+
val invreqMetadata = invoice.records.get[InvoiceRequestMetadata].get
137140
// We select invoice fields that we want to include in our payer proof.
138141
val knownLeaves: Set[(InvoiceTlv, Boolean)] = invoice.records.records
139142
.filterNot(_.isInstanceOf[Signature])
@@ -179,8 +182,6 @@ object PayerProof {
179182
.toSeq
180183
.sortBy(_._1.tag)
181184
.map { case (tlv, included) => PayerProofTree.Leaf(tlv, included) }
182-
// The invreq_metadata field must be provided (required in Bolt12Invoice).
183-
val invreqMetadata = invoice.records.get[InvoiceRequestMetadata].get
184185
// We include the leaf nonce for each invoice TLV we include in the payer proof.
185186
val leafNonces = leaves.collect {
186187
case leaf if leaf.included => PayerProofTree.leafNonce(invreqMetadata, leaf.tlv)
@@ -197,26 +198,29 @@ object PayerProof {
197198
// This TLV is not included in the payer proof: we add an entry with a marker.
198199
val markerNumber = previousTlv_opt match {
199200
case Some(tag) => tag + 1
200-
case None => omitted.lastOption.map(tag => tag + 1).getOrElse(UInt64(1))
201+
case None => omitted.lastOption match {
202+
case Some(tag) if tag == UInt64(239) => UInt64(1_000_000_000)
203+
case Some(tag) => tag + 1
204+
case None => UInt64(1)
205+
}
201206
}
202207
(omitted :+ markerNumber, None)
203208
}._1.toList
204209
val proofTree = PayerProofTree(leaves)
205210
val missingHashes = PayerProofTree.computeMissingHashes(proofTree, invreqMetadata)
206-
val payerSig = note_opt match {
207-
case Some(note) => signSchnorr(signatureTag, Crypto.sha256(ByteVector(note.getBytes) ++ proofTree.hash(invreqMetadata)), payerKey)
208-
case None => signSchnorr(signatureTag, Crypto.sha256(proofTree.hash(invreqMetadata)), payerKey)
209-
}
210211
val includedInvoiceTlvs = knownLeaves.collect { case (tlv, true) => tlv }.toSet[PayerProofTlv]
211212
val payerProofTlvs = Set(
212213
Some(InvoicePreimage(preimage)),
213214
Some(LeafHashes(leafNonces)),
214215
if (omittedTlvs.nonEmpty) Some(OmittedTlvs(omittedTlvs)) else None,
215216
if (missingHashes.nonEmpty) Some(MissingHashes(missingHashes)) else None,
216-
Some(PayerSignature(payerSig, note_opt))
217+
note_opt.map(note => ProofNote(note)),
217218
).flatten[PayerProofTlv]
218219
val includedUnknownTlvs = unknownLeaves.collect { case (tlv, true) => tlv }
219-
PayerProof(TlvStream(includedInvoiceTlvs ++ invoice.records.get[Signature].toSet ++ payerProofTlvs, includedUnknownTlvs))
220+
val tlvsWithoutProofSig = TlvStream(includedInvoiceTlvs ++ invoice.records.get[Signature].toSet ++ payerProofTlvs, includedUnknownTlvs)
221+
// We use a static 0x0000 hashing nonce instead of the invreq_metadata, which we don't want to disclose.
222+
val proofSig = signSchnorr(signatureTag, rootHash(tlvsWithoutProofSig, OfferCodecs.payerProofCodec), payerKey)
223+
PayerProof(tlvsWithoutProofSig.copy(records = tlvsWithoutProofSig.records + ProofSignature(proofSig)))
220224
}
221225

222226
/** When validating a proof, the leaf nonce is directly provided to avoid brute-forcing omitted fields. */
@@ -354,15 +358,14 @@ object PayerProof {
354358
if (records.get[InvoicePaymentHash].isEmpty) return Left(MissingRequiredTlv(UInt64(168)))
355359
if (records.get[InvoiceNodeId].isEmpty) return Left(MissingRequiredTlv(UInt64(176)))
356360
if (records.get[Signature].isEmpty) return Left(MissingRequiredTlv(UInt64(240)))
357-
if (records.get[InvoicePreimage].isEmpty) return Left(MissingRequiredTlv(UInt64(242)))
358-
if (records.get[PayerSignature].isEmpty) return Left(MissingRequiredTlv(UInt64(250)))
361+
if (records.get[ProofSignature].isEmpty) return Left(MissingRequiredTlv(UInt64(241)))
362+
if (records.get[InvoicePreimage].isEmpty) return Left(MissingRequiredTlv(UInt64(1001)))
359363
// The preimage must match the invoice's payment_hash.
360-
if (Crypto.sha256(records.get[InvoicePreimage].get.preimage) != records.get[InvoicePaymentHash].get.hash) return Left(InvalidTlvValue(UInt64(242)))
364+
if (Crypto.sha256(records.get[InvoicePreimage].get.preimage) != records.get[InvoicePaymentHash].get.hash) return Left(InvalidTlvValue(UInt64(1001)))
361365
val omittedTlvs = records.get[OmittedTlvs].map(_.missing).getOrElse(Nil)
362-
if (omittedTlvs.length != omittedTlvs.distinct.length) return Left(InvalidTlvValue(UInt64(244)))
363-
if (omittedTlvs.sorted != omittedTlvs) return Left(InvalidTlvValue(UInt64(244)))
364-
if (omittedTlvs.contains(UInt64(0))) return Left(InvalidTlvValue(UInt64(244)))
365-
if (omittedTlvs.exists(tag => tag >= UInt64(240) && tag <= UInt64(1000))) return Left(InvalidTlvValue(UInt64(244)))
366+
if (omittedTlvs.length != omittedTlvs.distinct.length) return Left(InvalidTlvValue(UInt64(1002)))
367+
if (omittedTlvs.sorted != omittedTlvs) return Left(InvalidTlvValue(UInt64(1002)))
368+
if (!omittedTlvs.forall(tag => (tag >= UInt64(1) && tag <= UInt64(239)) || (tag >= UInt64(1_000_000_000L) && tag <= UInt64(3_999_999_999L)))) return Left(InvalidTlvValue(UInt64(1002)))
366369
Right(PayerProof(records))
367370
}
368371

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,12 @@ object OfferCodecs {
201201
.typecase(UInt64(240), signature)
202202
).complete)
203203

204+
private val proofSig: Codec[ProofSignature] = tlvField(bytes64)
204205
private val preimage: Codec[InvoicePreimage] = tlvField(bytes32)
205206
private val omitted: Codec[OmittedTlvs] = tlvField(list(varint))
206207
private val missingHashes: Codec[MissingHashes] = tlvField(list(bytes32))
207208
private val leafHashes: Codec[LeafHashes] = tlvField(list(bytes32))
208-
private val payerSig: Codec[PayerSignature] = tlvField(bytes64 :: optional(bitsRemaining, utf8))
209+
private val proofNote: Codec[ProofNote] = tlvField(utf8)
209210

210211
val payerProofTlvCodec: DiscriminatorCodec[PayerProofTlv, UInt64] = discriminated[PayerProofTlv].by(varint)
211212
// Invoice part that must be copy-pasted from above
@@ -239,11 +240,12 @@ object OfferCodecs {
239240
.typecase(UInt64(176), invoiceNodeId)
240241
.typecase(UInt64(240), signature)
241242
// Payer proof part
242-
.typecase(UInt64(242), preimage)
243-
.typecase(UInt64(244), omitted)
244-
.typecase(UInt64(246), missingHashes)
245-
.typecase(UInt64(248), leafHashes)
246-
.typecase(UInt64(250), payerSig)
243+
.typecase(UInt64(241), proofSig)
244+
.typecase(UInt64(1001), preimage)
245+
.typecase(UInt64(1002), omitted)
246+
.typecase(UInt64(1003), missingHashes)
247+
.typecase(UInt64(1004), leafHashes)
248+
.typecase(UInt64(1005), proofNote)
247249

248250
val payerProofCodec: Codec[TlvStream[PayerProofTlv]] = catchAllCodec(TlvCodecs.tlvStream[PayerProofTlv](payerProofTlvCodec).complete)
249251

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ object OfferTypes {
224224
*/
225225
case class Signature(signature: ByteVector64) extends InvoiceRequestTlv with InvoiceTlv with PayerProofTlv
226226

227+
/** The payer signs the payer proof. */
228+
case class ProofSignature(signature: ByteVector64) extends PayerProofTlv
229+
227230
/** Preimage matching the invoice's [[InvoicePaymentHash]]. */
228231
case class InvoicePreimage(preimage: ByteVector32) extends PayerProofTlv
229232

@@ -236,8 +239,8 @@ object OfferTypes {
236239
/** The payer must include a nonce hash for each invoice TLV included in the payer proof. */
237240
case class LeafHashes(hashes: List[ByteVector32]) extends PayerProofTlv
238241

239-
/** The payer signs the payer proof, with an optional challenge (note). */
240-
case class PayerSignature(signature: ByteVector64, note_opt: Option[String]) extends PayerProofTlv
242+
/** An optional challenge may be included in the payer proof. */
243+
case class ProofNote(note: String) extends PayerProofTlv
241244

242245
private def isOfferTlv(tlv: GenericTlv): Boolean =
243246
// Offer TLVs are in the range [1, 79] or [1000000000, 1999999999].
@@ -484,9 +487,12 @@ object OfferTypes {
484487
// Encoding tlvs is always safe, unless we have a bug in our codecs, so we can call `.require` here.
485488
val encoded = codec.encode(tlvs).require
486489
// Decoding tlvs that we just encoded is safe as well.
487-
// This encoding/decoding step ensures that the resulting tlvs are ordered.
490+
// This encoding/decoding step ensures that the resulting tlvs are ordered and that we combine known and unknown TLVs.
488491
val genericTlvs = vector(genericTlv).decode(encoded).require.value
489-
val firstTlv = genericTlvs.minBy(_.tag)
492+
// The invreq_metadata, which is always the first TLV when provided (tag=0), is used as a hashing nonce for all
493+
// leaves of the tree to ensure that their values cannot be brute-forced when omitted in payer proofs.
494+
// If invreq_metadata isn't provided (e.g. for payer proofs) an empty 0x0000 must be used instead.
495+
val firstTlv = genericTlvs.find(_.tag == UInt64(0)).getOrElse(GenericTlv(UInt64(0), ByteVector.empty))
490496
val nonceKey = ByteVector("LnNonce".getBytes) ++ genericTlv.encode(firstTlv).require.bytes
491497

492498
def previousPowerOfTwo(n: Int): Int = {

0 commit comments

Comments
 (0)