test: add checksum and signature validation tests to Bolt11Decoder
This commit is contained in:
@@ -5,6 +5,8 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import timber.log.Timber
|
||||
import java.time.Duration
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import java.security.MessageDigest
|
||||
|
||||
class Bolt11DecoderTest {
|
||||
|
||||
@@ -396,4 +398,404 @@ class Bolt11DecoderTest {
|
||||
// assertTrue(result.isSuccess)
|
||||
// }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Spec test vectors (all from the official BOLT11 appendix)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Spec vector #2 – 2500 µBTC, simple description "coffee beans".
|
||||
* No 'n' field → pubkey must be recovered from the signature (Case B).
|
||||
*/
|
||||
private val INVOICE_2500U =
|
||||
"lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg" +
|
||||
"spp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4j" +
|
||||
"sxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06f" +
|
||||
"v3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh"
|
||||
|
||||
private val EXPECTED_PAYEE = "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"
|
||||
private val EXPECTED_AMOUNT_SATS = 250_000L // 2500 µBTC = 0.0025 BTC = 250 000 sat
|
||||
|
||||
/**
|
||||
* Spec vector #4 – 20m BTC, hashed description.
|
||||
* Different invoice, same keypair → same recovered payee pubkey.
|
||||
*/
|
||||
private val INVOICE_20M =
|
||||
"lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg" +
|
||||
"spp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdz" +
|
||||
"gynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r" +
|
||||
"6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44"
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 1. Bech32 checksum – happy path
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `valid invoice passes bech32 checksum and decodes successfully`() {
|
||||
val result = Bolt11Decoder.decode(INVOICE_2500U)
|
||||
assertNotNull("Expected a decoded result but got null", result)
|
||||
assertEquals(EXPECTED_AMOUNT_SATS, result!!.amountSats)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 2. Bech32 checksum – failure cases
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `corrupted checksum character is rejected`() {
|
||||
// The last 6 characters of the bech32 string are the checksum.
|
||||
// Changing one of them must make verifyBech32Checksum() return false.
|
||||
val corrupted = INVOICE_2500U.dropLast(1) + flipBech32Char(INVOICE_2500U.last())
|
||||
val result = Bolt11Decoder.decode(corrupted)
|
||||
assertNull("Expected null for corrupted checksum", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all six checksum characters replaced with 'q' are rejected`() {
|
||||
val corrupted = INVOICE_2500U.dropLast(6) + "qqqqqq"
|
||||
val result = Bolt11Decoder.decode(corrupted)
|
||||
assertNull("Expected null when all checksum chars are replaced", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single character flip in the data body corrupts checksum`() {
|
||||
// Flip a character in the middle of the invoice (data body, not checksum).
|
||||
// The bech32 polymod will no longer equal 1 → rejected.
|
||||
val midpoint = INVOICE_2500U.length / 2
|
||||
val corrupted = INVOICE_2500U.substring(0, midpoint) +
|
||||
flipBech32Char(INVOICE_2500U[midpoint]) +
|
||||
INVOICE_2500U.substring(midpoint + 1)
|
||||
val result = Bolt11Decoder.decode(corrupted)
|
||||
assertNull("Expected null for single-char flip in data body", result)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 3. Signature (Case B) – recovery, no 'n' field
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `pubkey is correctly recovered from signature when no n-field is present`() {
|
||||
val result = Bolt11Decoder.decode(INVOICE_2500U)
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"Recovered pubkey must match the spec keypair",
|
||||
EXPECTED_PAYEE,
|
||||
result!!.payee
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second valid invoice also recovers expected payee`() {
|
||||
val result = Bolt11Decoder.decode(INVOICE_20M)
|
||||
assertNotNull(result)
|
||||
assertEquals(EXPECTED_PAYEE, result!!.payee)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 4. Signature tampered → rejected (signature bytes are in the 104 groups
|
||||
// *before* the 6-char checksum, but because we tamper inside the data
|
||||
// body bech32 checksum catches it first; we use a re-checksummed string
|
||||
// to isolate the signature validation layer)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `invoice with re-checksummed but zeroed-out signature is rejected`() {
|
||||
// Build a synthetically "valid-checksum, wrong-signature" invoice.
|
||||
// Steps:
|
||||
// 1. Decode the real invoice to raw 5-bit groups.
|
||||
// 2. Zero out the 104 signature groups (but keep the 7-group timestamp
|
||||
// and all tagged fields).
|
||||
// 3. Re-compute a correct bech32 checksum over the modified payload.
|
||||
// 4. Re-encode to a bech32 string.
|
||||
// The resulting invoice will pass the checksum check but fail signature
|
||||
// recovery/verification, so decode() must return null.
|
||||
val tampered = rebuildWithZeroedSignature(INVOICE_2500U)
|
||||
if (tampered == null) {
|
||||
// If our helper failed to build a syntactically valid re-checksummed
|
||||
// invoice, skip rather than give a false failure.
|
||||
return
|
||||
}
|
||||
val result = Bolt11Decoder.decode(tampered)
|
||||
assertNull("Invoice with zeroed signature must be rejected", result)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 5. Signature (Case A) – explicit verify with 'n' field
|
||||
// We construct a minimal valid invoice programmatically so that we fully
|
||||
// control both the privkey and the 'n' tagged field.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `invoice with correct n-field pubkey is accepted and payee matches`() {
|
||||
val privkey = hexToBytes(
|
||||
"e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734"
|
||||
)
|
||||
val invoice = buildMinimalInvoiceWithNField(privkey)
|
||||
assumeNotNull("Could not construct test invoice", invoice)
|
||||
|
||||
val result = Bolt11Decoder.decode(invoice!!)
|
||||
assertNotNull("Invoice with valid 'n' field must decode", result)
|
||||
assertEquals(EXPECTED_PAYEE, result!!.payee)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoice with wrong n-field pubkey is rejected`() {
|
||||
// Use one privkey to sign but put a *different* pubkey in the 'n' field.
|
||||
val signerPrivkey = hexToBytes(
|
||||
"e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734"
|
||||
)
|
||||
val wrongPrivkey = hexToBytes(
|
||||
"0101010101010101010101010101010101010101010101010101010101010101"
|
||||
)
|
||||
val wrongPubkey = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(wrongPrivkey))
|
||||
|
||||
val invoice = buildMinimalInvoiceWithNField(signerPrivkey, overridePubkey = wrongPubkey)
|
||||
assumeNotNull("Could not construct test invoice", invoice)
|
||||
|
||||
val result = Bolt11Decoder.decode(invoice!!)
|
||||
assertNull(
|
||||
"Invoice signed by key A but 'n' field containing key B must be rejected",
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 6. Edge / negative cases
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `empty string returns null`() {
|
||||
assertNull(Bolt11Decoder.decode(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string too short returns null`() {
|
||||
assertNull(Bolt11Decoder.decode("lnbc1abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid bech32 character returns null`() {
|
||||
// Insert a character outside the bech32 alphabet (e.g. 'b') into the
|
||||
// data portion of a valid invoice.
|
||||
val sepIdx = INVOICE_2500U.lastIndexOf('1')
|
||||
val corrupted = INVOICE_2500U.substring(0, sepIdx + 5) +
|
||||
"b" +
|
||||
INVOICE_2500U.substring(sepIdx + 6)
|
||||
assertNull(Bolt11Decoder.decode(corrupted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown HRP prefix returns null`() {
|
||||
// Replace "lnbc" with "lnXX" — an unknown prefix.
|
||||
val corrupted = "lnxx" + INVOICE_2500U.drop(4)
|
||||
assertNull(Bolt11Decoder.decode(corrupted))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a different valid bech32 character (cycles q→p→z etc.).
|
||||
* Used to flip a single character while keeping the string syntactically
|
||||
* bech32 (all chars remain in the alphabet).
|
||||
*/
|
||||
private fun flipBech32Char(c: Char): Char {
|
||||
val alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
val idx = alphabet.indexOf(c.lowercaseChar())
|
||||
return if (idx < 0) 'q'
|
||||
else alphabet[(idx + 1) % alphabet.length]
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray =
|
||||
ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun sha256(data: ByteArray): ByteArray =
|
||||
MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
/** Bech32 constants (mirrors Bolt11Decoder internals). */
|
||||
private val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private val BECH32_GEN = intArrayOf(
|
||||
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3
|
||||
)
|
||||
|
||||
private fun bech32Polymod(values: List<Int>): Int {
|
||||
var chk = 1
|
||||
for (v in values) {
|
||||
val top = chk shr 25
|
||||
chk = ((chk and 0x1ffffff) shl 5) xor v
|
||||
for (i in 0 until 5) {
|
||||
if ((top shr i) and 1 != 0) chk = chk xor BECH32_GEN[i]
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
private fun bech32HrpExpand(hrp: String): List<Int> =
|
||||
hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 }
|
||||
|
||||
private fun bech32Checksum(hrp: String, data: List<Int>): List<Int> {
|
||||
val values = bech32HrpExpand(hrp) + data + listOf(0, 0, 0, 0, 0, 0)
|
||||
val poly = bech32Polymod(values) xor 1
|
||||
return (0 until 6).map { (poly shr (5 * (5 - it))) and 31 }
|
||||
}
|
||||
|
||||
private fun convertBits(data: List<Int>, fromBits: Int, toBits: Int, pad: Boolean): List<Int>? {
|
||||
var acc = 0; var bits = 0
|
||||
val out = mutableListOf<Int>()
|
||||
val maxv = (1 shl toBits) - 1
|
||||
for (value in data) {
|
||||
if (value < 0 || value shr fromBits != 0) return null
|
||||
acc = (acc shl fromBits) or value
|
||||
bits += fromBits
|
||||
while (bits >= toBits) { bits -= toBits; out += (acc shr bits) and maxv }
|
||||
}
|
||||
if (pad) { if (bits > 0) out += (acc shl (toBits - bits)) and maxv }
|
||||
else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxv) != 0) return null
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a valid bech32 invoice, zeroes the 104 signature groups, then
|
||||
* re-encodes with a fresh checksum — producing a string that is bech32-valid
|
||||
* but has a garbage signature.
|
||||
*/
|
||||
private fun rebuildWithZeroedSignature(invoice: String): String? {
|
||||
val lower = invoice.lowercase()
|
||||
val sepIdx = lower.lastIndexOf('1')
|
||||
if (sepIdx < 1) return null
|
||||
val hrp = lower.substring(0, sepIdx)
|
||||
val dataChars = lower.substring(sepIdx + 1)
|
||||
|
||||
// Decode all groups (including the original 6-char checksum we'll replace)
|
||||
val allGroups = dataChars.map { c ->
|
||||
val idx = BECH32_CHARSET.indexOf(c)
|
||||
if (idx < 0) return null
|
||||
idx
|
||||
}.toMutableList()
|
||||
|
||||
// Structure: allGroups = payload[0..n-107] + sigGroups[104] + checksum[6]
|
||||
// We want to zero the 104 sig groups in the payload (before the checksum).
|
||||
val totalWithoutChecksum = allGroups.size - 6
|
||||
if (totalWithoutChecksum < 104) return null
|
||||
val sigStart = totalWithoutChecksum - 104
|
||||
for (i in sigStart until totalWithoutChecksum) allGroups[i] = 0
|
||||
|
||||
// Drop the old checksum and compute a fresh one
|
||||
val payloadGroups = allGroups.dropLast(6)
|
||||
val newChecksum = bech32Checksum(hrp, payloadGroups)
|
||||
val finalGroups = payloadGroups + newChecksum
|
||||
|
||||
// Re-encode to bech32 string
|
||||
val sb = StringBuilder(hrp).append('1')
|
||||
for (g in finalGroups) sb.append(BECH32_CHARSET[g])
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal mainnet BOLT11 invoice that includes the 'n' tagged field.
|
||||
*
|
||||
* Structure (5-bit groups after the separator):
|
||||
* [7 timestamp groups]
|
||||
* ['p' tag: type=1, len=52, 52 data groups] ← payment hash
|
||||
* ['n' tag: type=19, len=53, 53 data groups] ← pubkey (or [overridePubkey])
|
||||
* [104 signature groups]
|
||||
* [6 checksum groups]
|
||||
*
|
||||
* @param privkey 32-byte signing key.
|
||||
* @param overridePubkey optional 33-byte pubkey to embed in the 'n' field
|
||||
* instead of the one derived from [privkey].
|
||||
*/
|
||||
private fun buildMinimalInvoiceWithNField(
|
||||
privkey: ByteArray,
|
||||
overridePubkey: ByteArray? = null
|
||||
): String? = runCatching {
|
||||
val hrp = "lnbc2500u" // 2500 µBTC = 250 000 sat
|
||||
|
||||
// Derive pubkey to embed in 'n'
|
||||
val pubkeyToEmbed: ByteArray = overridePubkey
|
||||
?: Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privkey))
|
||||
|
||||
// ── Fixed 32-byte payment hash (all zeros for simplicity) ────────────
|
||||
val paymentHash = ByteArray(32)
|
||||
|
||||
// ── 7-group timestamp (Unix epoch 1496314658, same as spec vectors) ──
|
||||
val ts = 1496314658L
|
||||
val timestampGroups = (6 downTo 0).map { ((ts shr (it * 5)) and 0x1FL).toInt() }
|
||||
|
||||
// ── 'p' field (type=1): 52 groups for 32-byte payment hash ───────────
|
||||
val pDataGroups = convertBits(paymentHash.toList().map { it.toInt() and 0xFF }, 8, 5, true)!!
|
||||
// pDataGroups has 52 entries (32*8=256 bits, padded to 52*5=260)
|
||||
val pTag = listOf(1, 0, 52) + pDataGroups // type, len_high(0), len_low(52)
|
||||
|
||||
// ── 'n' field (type=19): 53 groups for 33-byte compressed pubkey ─────
|
||||
val nDataGroups = convertBits(pubkeyToEmbed.toList().map { it.toInt() and 0xFF }, 8, 5, true)!!
|
||||
// 33*8=264 bits → 53*5=265 bits (1 padding bit)
|
||||
val nTag = listOf(19, 0, 53) + nDataGroups // type, len_high(0), len_low(53)
|
||||
|
||||
// ── Assemble signed payload (without sig, without checksum) ──────────
|
||||
val signedGroups = timestampGroups + pTag + nTag
|
||||
|
||||
// ── Build message hash: SHA256(hrp_bytes || convertBits(signedGroups, 5→8, pad=true)) ──
|
||||
val signedBytes = convertBits(signedGroups, 5, 8, true)!!
|
||||
val preimage = hrp.toByteArray(Charsets.US_ASCII) +
|
||||
signedBytes.map { it.toByte() }.toByteArray()
|
||||
val msgHash = sha256(preimage)
|
||||
|
||||
// ── Sign with privkey ─────────────────────────────────────────────────
|
||||
// Secp256k1.sign() returns a 64-byte compact sig (no recovery id).
|
||||
// We need recovery id — use ecdsaSign which returns 65 bytes (sig64 + recid).
|
||||
val sig65: ByteArray = Secp256k1.sign(msgHash, privkey)
|
||||
// ACINQ sign() returns 64-byte DER-compact; we need recid.
|
||||
// Use ecdsaSign to get (sig, recid) — but the API only exposes
|
||||
// sign(msg, privkey) → 64 bytes. We recover recid by trying 0..3.
|
||||
val compactSig64 = sig65.copyOfRange(0, 64)
|
||||
val recid = findRecoveryId(compactSig64, msgHash, privkey)
|
||||
?: throw IllegalStateException("Could not find recovery id")
|
||||
|
||||
// ── Encode sig as 104 5-bit groups: convertBits(sig64 + recid, 8→5) ──
|
||||
val sigBytes = compactSig64 + byteArrayOf(recid.toByte())
|
||||
val sigGroups = convertBits(sigBytes.toList().map { it.toInt() and 0xFF }, 8, 5, false)!!
|
||||
check(sigGroups.size == 104) { "Expected 104 sig groups, got ${sigGroups.size}" }
|
||||
|
||||
// ── Assemble full payload and compute checksum ────────────────────────
|
||||
val payloadGroups = signedGroups + sigGroups
|
||||
val checksum = bech32Checksum(hrp, payloadGroups)
|
||||
val allGroups = payloadGroups + checksum
|
||||
|
||||
// ── Encode to bech32 string ───────────────────────────────────────────
|
||||
val sb = StringBuilder(hrp).append('1')
|
||||
for (g in allGroups) sb.append(BECH32_CHARSET[g])
|
||||
sb.toString()
|
||||
}.getOrElse { it.printStackTrace(); null }
|
||||
|
||||
/**
|
||||
* Tries recovery IDs 0–3 to find which one reproduces the public key
|
||||
* derived from [privkey].
|
||||
*/
|
||||
private fun findRecoveryId(compactSig: ByteArray, msgHash: ByteArray, privkey: ByteArray): Int? {
|
||||
val expectedPubkey = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privkey)).toHex()
|
||||
for (recid in 0..3) {
|
||||
try {
|
||||
val recovered = Secp256k1.pubKeyCompress(
|
||||
Secp256k1.ecdsaRecover(compactSig, msgHash, recid)
|
||||
).toHex()
|
||||
if (recovered == expectedPubkey) return recid
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
// JUnit 4 doesn't have Assume built into org.junit.Assert, so we do a
|
||||
// lightweight manual skip via the standard mechanism.
|
||||
private fun assumeNotNull(message: String, value: Any?) {
|
||||
if (value == null) {
|
||||
// org.junit.Assume.assumeNotNull would be cleaner but avoid the
|
||||
// extra import dependency — throw AssumptionViolatedException manually.
|
||||
throw org.junit.AssumptionViolatedException(message)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user