From f2d6aeb13b4576076a17bbad3c3a2fe9e452bcd9 Mon Sep 17 00:00:00 2001 From: rasputin Date: Fri, 12 Jun 2026 17:32:23 +0200 Subject: [PATCH] test: add checksum and signature validation tests to Bolt11Decoder --- app/build.gradle.kts | 1 + .../gudariwallet/util/Bolt11DecoderTest.kt | 402 ++++++++++++++++++ 2 files changed, 403 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81d69e3..225d586 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -185,6 +185,7 @@ dependencies { // secp256k1 implementation(libs.secp256k1.kmp.android) testImplementation(libs.secp256k1.kmp.jvm) + testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.15.0") // lyricist implementation(libs.lyricist) diff --git a/app/src/test/java/com/bitcointxoko/gudariwallet/util/Bolt11DecoderTest.kt b/app/src/test/java/com/bitcointxoko/gudariwallet/util/Bolt11DecoderTest.kt index b414b80..fb70a5a 100644 --- a/app/src/test/java/com/bitcointxoko/gudariwallet/util/Bolt11DecoderTest.kt +++ b/app/src/test/java/com/bitcointxoko/gudariwallet/util/Bolt11DecoderTest.kt @@ -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 { + 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 = + hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 } + + private fun bech32Checksum(hrp: String, data: List): List { + 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, fromBits: Int, toBits: Int, pad: Boolean): List? { + var acc = 0; var bits = 0 + val out = mutableListOf() + 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) + } + } + }