mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
fix(bolt12): audit fixes — offer binding, verified-only counting, lower-amount dedup, codec hardening
From an adversarial audit of the BOLT12-zap feature. Security / correctness: - Offer↔invoice binding: `cryptoVerified=true` was asserted even when the offer had no `offer_issuer_id` or used blinded paths — cases where the invoice's node key is payer-chosen and can't be tied to the offer. An attacker could self-sign a "verified" proof having paid nothing. Now cryptoVerified requires the invoice to be provably the offer's (issuer_id present, no paths, invoice_node_id == issuer); unbindable proofs are accepted but flagged unverified, not verified. Definite contradictions still hard-reject. - Counting: `updateZapTotal` now counts ONLY crypto-verified BOLT12 zaps. An unverified (compressed / unbindable) proof carries a self-chosen preimage+amount with no settled- payment guarantee, so counting it let anyone inflate a note's total for free. Unverified entries stay stored + shown (dimmed), never summed. - Dedup: `innerAddBolt12Zap` now honors the NIP's "count the LOWER amount for the same payment hash" rule (was order-dependent last-writer-wins, inflatable by re-publishing a bigger amount tag). Keeps the stronger verification flag. - Precision: divide millisats in BigDecimal, so fractional sats survive and match the millisat-native lightning column (was integer `/1000`, flooring sub-sat zaps to 0). Codec hardening (quartz): - TLV length now range-checked (was a signed compare that let a high-bit BigSize length slip through and get truncated by toInt()). - BigSize enforces minimal encoding (also rejects >=2^63 values that read back negative). - bech32 alphabet membership is O(1) via a lookup table (was O(32n) indexOf per char). UI: - ReusableZapButton's "you zapped" gate now includes bolt12Zaps (and nutzaps/onchain), so a BOLT12-only zap correctly shows the zapped state. - The reactions gallery renders the blank/unknown author for anonymous zaps, matching the standalone card (was showing the throwaway ephemeral key's avatar). Tests: validator issuer-less-offer downgrade; TLV non-minimal-BigSize + oversized-length rejection; model lower-amount dedup (both orderings), verified-only counting, and fractional-sat survival. NoteBolt12ZapTest 6→8, Bolt12ZapValidatorTest 11→12, TlvTest 6→8. The audit also surfaced a NIP-level gap that is NOT fixable in code and is captured in the plan doc: there is no offer↔recipient-identity binding, so even a crypto-verified proof only proves payment to the *embedded* offer, not to the p-tagged recipient. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
@@ -739,14 +739,20 @@ open class Note(
|
||||
): Boolean =
|
||||
syncLock.withLock {
|
||||
val existing = bolt12Zaps[paymentHashHex]
|
||||
if (existing != null) {
|
||||
// Same settled payment (dedup by invoice_payment_hash) — a relay echo.
|
||||
if (entry == existing) return@withLock false
|
||||
// Prefer a fully crypto-verified entry; never let an unverified
|
||||
// (compressed-proof) republish overwrite a verified one.
|
||||
if (!entry.cryptoVerified && existing.cryptoVerified) return@withLock false
|
||||
}
|
||||
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, entry)
|
||||
val merged =
|
||||
if (existing == null) {
|
||||
entry
|
||||
} else {
|
||||
// Same settled payment (dedup by invoice_payment_hash). NIP-XX: count
|
||||
// only one, and if amounts differ, keep the LOWER — so a re-publish
|
||||
// with a bigger amount tag can't inflate the total. Keep the stronger
|
||||
// verification flag, and the source of whichever entry we keep the
|
||||
// amount from.
|
||||
val keepEntry = if (entry.amountMillisats < existing.amountMillisats) entry else existing
|
||||
keepEntry.copy(cryptoVerified = entry.cryptoVerified || existing.cryptoVerified)
|
||||
}
|
||||
if (merged == existing) return@withLock false
|
||||
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, merged)
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
@@ -1102,11 +1108,16 @@ open class Note(
|
||||
sumOfAmounts += BigDecimal(entry.claimedSats)
|
||||
}
|
||||
|
||||
// NIP-XX BOLT12 zaps — validated synchronously at consume time (the `lnp`
|
||||
// payer proof is a self-contained settlement proof), so every stored entry
|
||||
// counts, converting its millisat amount to sats like the lightning path.
|
||||
// NIP-XX BOLT12 zaps — count only the crypto-verified ones. An unverified
|
||||
// (compressed, or offer-unbindable) proof carries a self-chosen preimage,
|
||||
// amount, and payment hash with no settled-payment guarantee, so counting it
|
||||
// would let anyone inflate a note's total for free. Unverified entries stay
|
||||
// stored and shown (dimmed) but never sum. Divide in BigDecimal so fractional
|
||||
// sats survive and match the millisat-native lightning column above.
|
||||
bolt12Zaps.values.forEach { entry ->
|
||||
sumOfAmounts += BigDecimal(entry.amountMillisats / 1000)
|
||||
if (entry.cryptoVerified) {
|
||||
sumOfAmounts += BigDecimal(entry.amountMillisats).divide(BigDecimal(1000))
|
||||
}
|
||||
}
|
||||
|
||||
zapsAmount = sumOfAmounts
|
||||
|
||||
Reference in New Issue
Block a user