diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt index cd71f37..8028eb1 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt @@ -20,7 +20,6 @@ object Bolt11Decoder { private val KNOWN_PREFIXES = listOf("lnbcrt", "lntbs", "lntb", "lnbc") - // ── NEW: map prefix → bech32 address HRP for fallback address encoding ─── private val PREFIX_TO_ADDRESS_HRP = mapOf( "lnbc" to "bc", "lntb" to "tb", @@ -29,23 +28,22 @@ object Bolt11Decoder { ) private val MULTIPLIER_MSAT: Map = mapOf( - 'm' to 100_000_000L, // milli-BTC = 10^8 msat - 'u' to 100_000L, // micro-BTC = 10^5 msat - 'n' to 100L, // nano-BTC = 10^2 msat - 'p' to 1L // pico-BTC = 10^-1 msat (must be multiple of 10) + 'm' to 100_000_000L, + 'u' to 100_000L, + 'n' to 100L, + 'p' to 1L ) private const val TAG_PAYMENT_HASH = 1 private const val TAG_DESCRIPTION = 13 private const val TAG_PAYEE_PUBKEY = 19 private const val TAG_ROUTE_HINTS = 3 - private const val TAG_EXPIRY = 6 // ── NEW - private const val TAG_FALLBACK_ADDR = 9 // ── NEW + private const val TAG_EXPIRY = 6 + private const val TAG_FALLBACK_ADDR = 9 private const val SIGNATURE_GROUPS = 104 - private const val DEFAULT_EXPIRY_S = 3600L // ── NEW: BOLT11 default + private const val DEFAULT_EXPIRY_S = 3600L - // ── bech32 address encoding constants ──────────────────────────────────── private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" private val BECH32_GEN = intArrayOf( 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 @@ -94,8 +92,7 @@ object Bolt11Decoder { val amountSats = amountMsat / 1_000L // ── 3. Decode bech32 → 5-bit groups ────────────────────────────────── - val payload = dataChars.dropLast(6) - val groups = payload.map { c -> + val allGroups = dataChars.map { c -> val idx = CHARSET.indexOf(c) if (idx < 0) { Timber.w("Invalid bech32 character: '$c'") @@ -104,15 +101,26 @@ object Bolt11Decoder { idx } + // ── NEW: Validate bech32 checksum ───────────────────────────────────── + // The full bech32 string (including the 6-char checksum) must produce + // a polymod of exactly 1 when fed through the verifier. + if (!verifyBech32Checksum(hrp, allGroups)) { + Timber.w("Invalid bech32 checksum — invoice may be corrupted or tampered") + return null + } + + // Strip the 6 checksum groups now that they are validated + val payload = allGroups.dropLast(6) + // ── 4. Split signature / signed data ────────────────────────────────── - val sigGroups = groups.takeLast(SIGNATURE_GROUPS) - val signedGroups = groups.dropLast(SIGNATURE_GROUPS) + val sigGroups = payload.takeLast(SIGNATURE_GROUPS) + val signedGroups = payload.dropLast(SIGNATURE_GROUPS) if (signedGroups.size < 7) { Timber.w("Data part too short for timestamp") return null } - // ── NEW: decode 35-bit timestamp (7 groups × 5 bits) ───────────────── + // ── Decode 35-bit timestamp (7 groups × 5 bits) ─────────────────────── var timestampSecs = 0L for (i in 0 until 7) { timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong() @@ -122,15 +130,14 @@ object Bolt11Decoder { val taggedGroups = signedGroups.drop(7) // ── 5. Parse tagged fields ──────────────────────────────────────────── - var paymentHash: String? = null // ── NEW - var memo: String? = null - var payee: String? = null - var expirySecs: Long? = null // ── NEW (null = use default) - var fallbackAddress: String? = null // ── NEW + var paymentHash: String? = null + var memo: String? = null + var payeeHex: String? = null // from 'n' field (if present) + var expirySecs: Long? = null + var fallbackAddress: String? = null val routeHintRoutes = mutableListOf>() - // bech32 address HRP for this network (used for fallback address encoding) - val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc" // ── NEW + val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc" var pos = 0 while (pos + 2 < taggedGroups.size) { @@ -148,9 +155,7 @@ object Bolt11Decoder { when (type) { - // ── NEW ─────────────────────────────────────────────────────── TAG_PAYMENT_HASH -> { - // Fixed: 52 groups × 5 = 260 bits → 32 bytes + 4 padding bits if (fieldGroups.size != 52) { Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)") continue @@ -170,23 +175,18 @@ object Bolt11Decoder { continue } val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue - if (bytes.size == 33) payee = bytes.toByteArray().toHex() + if (bytes.size == 33) payeeHex = bytes.toByteArray().toHex() } - // ── NEW ─────────────────────────────────────────────────────── TAG_EXPIRY -> { - // Variable-length big-endian integer in 5-bit groups var secs = 0L for (g in fieldGroups) secs = (secs shl 5) or g.toLong() expirySecs = secs } - // ── NEW ─────────────────────────────────────────────────────── TAG_FALLBACK_ADDR -> { fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp) - if (fallbackAddress == null) { - Timber.w("Could not decode fallback address") - } + if (fallbackAddress == null) Timber.w("Could not decode fallback address") } TAG_ROUTE_HINTS -> { @@ -196,11 +196,11 @@ object Bolt11Decoder { while (hopPos + 51 <= bytes.size) { val hop = bytes.subList(hopPos, hopPos + 51) hops += RouteHintHop( - publicKey = hop.subList(0, 33).toByteArray().toHex(), - shortChannelId = hop.subList(33, 41).toByteArray().toScidString(), - baseFeeMsat = hop.subList(41, 45).toByteArray().toUInt32(), - ppmFee = hop.subList(45, 49).toByteArray().toUInt32(), - cltvExpiryDelta = hop.subList(49, 51).toByteArray().toUInt16() + publicKey = hop.subList(0, 33).toByteArray().toHex(), + shortChannelId = hop.subList(33, 41).toByteArray().toScidString(), + baseFeeMsat = hop.subList(41, 45).toByteArray().toUInt32(), + ppmFee = hop.subList(45, 49).toByteArray().toUInt32(), + cltvExpiryDelta = hop.subList(49, 51).toByteArray().toUInt16() ) hopPos += 51 } @@ -211,16 +211,26 @@ object Bolt11Decoder { } } - // ── NEW: compute expiresAt ──────────────────────────────────────────── + // ── Compute expiresAt ───────────────────────────────────────────────── val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S) - // ── 6. Recover payee from signature if 'n' field was absent ─────────── - if (payee == null) { - payee = recoverPayee(hrp, signedGroups, sigGroups) - if (payee != null) Timber.d("Recovered payee pubkey from signature") - } + // ── NEW: Validate / recover payee from signature ─────────────────────── + // + // BOLT11 §Signature: + // signed_data = hrp_bytes || convertBits(signedGroups, 5→8, pad=true) + // message = SHA-256(signed_data) + // signature = 64-byte compact ECDSA sig + 1-byte recovery id + // + // Case A – 'n' field present: VERIFY the signature against the known pubkey. + // Case B – 'n' field absent : RECOVER the pubkey from the signature. + // Either way a failure is fatal. + val payee: String? = verifyOrRecoverPayee(hrp, signedGroups, sigGroups, payeeHex) + ?: run { + Timber.w("Signature validation failed — rejecting invoice") + return null + } - // ── NEW: paymentHash is required — fail loudly if absent ───────────── + // ── paymentHash is required ─────────────────────────────────────────── if (paymentHash == null) { Timber.w("Invoice missing required 'p' (payment hash) field") return null @@ -228,10 +238,12 @@ object Bolt11Decoder { val routeHints = routeHintRoutes.flatten() - Timber.d("Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " + - "payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " + - "createdAt: $createdAt, expiresAt: $expiresAt, " + - "fallback: $fallbackAddress, paymentHash: $paymentHash") + Timber.d( + "Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " + + "payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " + + "createdAt: $createdAt, expiresAt: $expiresAt, " + + "fallback: $fallbackAddress, paymentHash: $paymentHash" + ) return DecodedBolt11( amountSats = amountSats, @@ -245,47 +257,112 @@ object Bolt11Decoder { ) } + // ── NEW: Bech32 checksum verification ───────────────────────────────────── + + /** + * Returns true iff the bech32 checksum over [hrp] + [data] (all groups, + * including the 6 checksum groups) is valid, i.e. polymod == 1. + */ + private fun verifyBech32Checksum(hrp: String, data: List): Boolean { + val values = bech32HrpExpand(hrp) + data + return bech32Polymod(values) == 1 + } + + // ── NEW: Unified signature verify / recover ─────────────────────────────── + + /** + * Validates the invoice signature. + * + * @param knownPubkeyHex hex-encoded compressed pubkey from the 'n' field, + * or null if the field was absent. + * + * - When [knownPubkeyHex] is non-null (Case A): performs an explicit + * ECDSA verify; returns the pubkey hex on success, null on failure. + * - When [knownPubkeyHex] is null (Case B): recovers the pubkey from the + * signature; returns the recovered pubkey hex, or null on failure. + */ + private fun verifyOrRecoverPayee( + hrp: String, + signedGroups: List, + sigGroups: List, + knownPubkeyHex: String? + ): String? = runCatching { + // Build the message hash (identical for both cases) + val dataBytes = convertBits(signedGroups, 5, 8, true)?.toByteArray() + ?: return null + val preimage = hrp.toByteArray(Charsets.US_ASCII) + dataBytes + val message = sha256(preimage) + + // Decode the 65-byte signature (64 compact + 1 recovery id) + val sigBytes = convertBits(sigGroups, 5, 8, false)?.toByteArray() + ?: return null + if (sigBytes.size != 65) { + Timber.w("Unexpected signature length: ${sigBytes.size}") + return null + } + val compactSig = sigBytes.copyOfRange(0, 64) + val recid = sigBytes[64].toInt() + if (recid !in 0..3) { + Timber.w("Invalid recovery id: $recid") + return null + } + + if (knownPubkeyHex != null) { + // ── Case A: explicit verify ─────────────────────────────────────── + val pubkeyBytes = hexToByteArray(knownPubkeyHex) + + // Secp256k1.ecdsaVerify expects a normalised DER or compact sig; + // the library's verifyCompact variant takes (sig64, msg32, pubkey). + val valid = Secp256k1.verify(compactSig, message, pubkeyBytes) + if (!valid) { + Timber.w("ECDSA signature verification failed for known pubkey") + return null + } + Timber.d("Signature verified against 'n' field pubkey") + knownPubkeyHex // return the already-known pubkey + } else { + // ── Case B: recovery ────────────────────────────────────────────── + val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid) + val recovered = Secp256k1.pubKeyCompress(uncompressed).toHex() + Timber.d("Payee pubkey recovered from signature: $recovered") + recovered + } + }.getOrElse { + Timber.w("Signature processing failed: ${it.message}") + null + } + // ── Fallback address decoding ───────────────────────────────────────────── /** * Decodes a BOLT11 'f' (fallback address) tagged field. * * The first 5-bit group is the address version/type: - * 0 = P2PKH → 20-byte hash → Base58Check (version byte 0x00 mainnet / 0x6f testnet) - * 1 = P2SH → 20-byte hash → Base58Check (version byte 0x05 mainnet / 0xc4 testnet) + * 0 = P2PKH → 20-byte hash → Base58Check + * 1 = P2SH → 20-byte hash → Base58Check * 17 = P2WPKH → 20-byte witness program → bech32 address (witness version 0) * 18 = P2WSH → 32-byte witness program → bech32 address (witness version 0) */ private fun decodeFallbackAddress(fieldGroups: List, addrHrp: String): String? { if (fieldGroups.isEmpty()) return null - val version = fieldGroups[0] + val version = fieldGroups[0] val dataGroups = fieldGroups.drop(1) return when (version) { 0 -> { - // P2PKH val hash = convertBits(dataGroups, 5, 8, false) ?: return null if (hash.size != 20) return null val versionByte = if (addrHrp == "bc") 0x00 else 0x6f encodeBase58Check(versionByte, hash.toByteArray()) } 1 -> { - // P2SH val hash = convertBits(dataGroups, 5, 8, false) ?: return null if (hash.size != 20) return null val versionByte = if (addrHrp == "bc") 0x05 else 0xc4 encodeBase58Check(versionByte, hash.toByteArray()) } - 17 -> { - // P2WPKH — witness version 0, 20-byte program - // dataGroups are already in 5-bit form for bech32 encoding; - // prepend witness version 0 as the first data group - encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups) - } - 18 -> { - // P2WSH — witness version 0, 32-byte program - encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups) - } + 17 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups) + 18 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups) else -> { Timber.w("Unknown fallback address version: $version") null @@ -293,10 +370,6 @@ object Bolt11Decoder { } } - /** - * Encodes a Base58Check address: version_byte || payload || checksum. - * Checksum = first 4 bytes of SHA-256(SHA-256(version_byte || payload)). - */ private fun encodeBase58Check(versionByte: Int, payload: ByteArray): String { val versioned = byteArrayOf(versionByte.toByte()) + payload val checksum = sha256(sha256(versioned)).copyOfRange(0, 4) @@ -313,24 +386,17 @@ object Bolt11Decoder { sb.append(ALPHABET[remainder.toInt()]) num = quotient } - // Leading zero bytes → leading '1' characters for (byte in input) { if (byte == 0.toByte()) sb.append('1') else break } return sb.reverse().toString() } - /** - * Encodes a native SegWit (bech32) address. - * [dataGroups] are the 5-bit groups of the witness program (as stored in the 'f' field, - * already in 5-bit form — no convertBits needed). - */ private fun encodeBech32Address( hrp: String, witnessVersion: Int, dataGroups: List ): String { - // data = [witnessVersion] + dataGroups val data = listOf(witnessVersion) + dataGroups val checksum = bech32Checksum(hrp, data) val sb = StringBuilder(hrp).append('1') @@ -359,41 +425,14 @@ object Bolt11Decoder { return chk } - // ── secp256k1 ECDSA public-key recovery ────────────────────────────────── - - private fun recoverPayee( - hrp: String, - signedGroups: List, - sigGroups: List - ): String? = runCatching { - val dataBytes = convertBits(signedGroups, 5, 8, true) ?: return null - val preimage = hrp.toByteArray(Charsets.US_ASCII) + dataBytes.toByteArray() - val message = sha256(preimage) - - val sigBytes = convertBits(sigGroups, 5, 8, false)?.toByteArray() ?: return null - if (sigBytes.size != 65) { - Timber.w("Unexpected signature length: ${sigBytes.size}") - return null - } - val compactSig = sigBytes.copyOfRange(0, 64) - val recid = sigBytes[64].toInt() - if (recid !in 0..3) { - Timber.w("Invalid recovery id: $recid") - return null - } - - val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid) - Secp256k1.pubKeyCompress(uncompressed).toHex() - }.getOrElse { - Timber.w("Payee recovery failed: ${it.message}") - null - } - - // ── Helpers ─────────────────────────────────────────────────────────────── + // ── Helpers ──────────────────────────────────────────────────────────────── private fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input) + private fun hexToByteArray(hex: String): ByteArray = + ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + private fun parseAmountMsat(amountStr: String): Long? { if (amountStr.isEmpty()) return 0L val lastChar = amountStr.last() @@ -405,7 +444,7 @@ object Bolt11Decoder { } else { val number = amountStr.toLongOrNull() ?: return null if (number <= 0L) return null - number * 100_000_000_000L // 1 BTC = 10^11 msat + number * 100_000_000_000L } }