feat: add checksum and signature validation to Bolt11Decoder

This commit is contained in:
2026-06-12 17:08:35 +02:00
parent 321c3d9c55
commit 91dc24d1e1
@@ -20,7 +20,6 @@ object Bolt11Decoder {
private val KNOWN_PREFIXES = listOf("lnbcrt", "lntbs", "lntb", "lnbc") 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( private val PREFIX_TO_ADDRESS_HRP = mapOf(
"lnbc" to "bc", "lnbc" to "bc",
"lntb" to "tb", "lntb" to "tb",
@@ -29,23 +28,22 @@ object Bolt11Decoder {
) )
private val MULTIPLIER_MSAT: Map<Char, Long> = mapOf( private val MULTIPLIER_MSAT: Map<Char, Long> = mapOf(
'm' to 100_000_000L, // milli-BTC = 10^8 msat 'm' to 100_000_000L,
'u' to 100_000L, // micro-BTC = 10^5 msat 'u' to 100_000L,
'n' to 100L, // nano-BTC = 10^2 msat 'n' to 100L,
'p' to 1L // pico-BTC = 10^-1 msat (must be multiple of 10) 'p' to 1L
) )
private const val TAG_PAYMENT_HASH = 1 private const val TAG_PAYMENT_HASH = 1
private const val TAG_DESCRIPTION = 13 private const val TAG_DESCRIPTION = 13
private const val TAG_PAYEE_PUBKEY = 19 private const val TAG_PAYEE_PUBKEY = 19
private const val TAG_ROUTE_HINTS = 3 private const val TAG_ROUTE_HINTS = 3
private const val TAG_EXPIRY = 6 // ── NEW private const val TAG_EXPIRY = 6
private const val TAG_FALLBACK_ADDR = 9 // ── NEW private const val TAG_FALLBACK_ADDR = 9
private const val SIGNATURE_GROUPS = 104 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 const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val BECH32_GEN = intArrayOf( private val BECH32_GEN = intArrayOf(
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3
@@ -94,8 +92,7 @@ object Bolt11Decoder {
val amountSats = amountMsat / 1_000L val amountSats = amountMsat / 1_000L
// ── 3. Decode bech32 → 5-bit groups ────────────────────────────────── // ── 3. Decode bech32 → 5-bit groups ──────────────────────────────────
val payload = dataChars.dropLast(6) val allGroups = dataChars.map { c ->
val groups = payload.map { c ->
val idx = CHARSET.indexOf(c) val idx = CHARSET.indexOf(c)
if (idx < 0) { if (idx < 0) {
Timber.w("Invalid bech32 character: '$c'") Timber.w("Invalid bech32 character: '$c'")
@@ -104,15 +101,26 @@ object Bolt11Decoder {
idx 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 ────────────────────────────────── // ── 4. Split signature / signed data ──────────────────────────────────
val sigGroups = groups.takeLast(SIGNATURE_GROUPS) val sigGroups = payload.takeLast(SIGNATURE_GROUPS)
val signedGroups = groups.dropLast(SIGNATURE_GROUPS) val signedGroups = payload.dropLast(SIGNATURE_GROUPS)
if (signedGroups.size < 7) { if (signedGroups.size < 7) {
Timber.w("Data part too short for timestamp") Timber.w("Data part too short for timestamp")
return null return null
} }
// ── NEW: decode 35-bit timestamp (7 groups × 5 bits) ───────────────── // ── Decode 35-bit timestamp (7 groups × 5 bits) ───────────────────────
var timestampSecs = 0L var timestampSecs = 0L
for (i in 0 until 7) { for (i in 0 until 7) {
timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong() timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong()
@@ -122,15 +130,14 @@ object Bolt11Decoder {
val taggedGroups = signedGroups.drop(7) val taggedGroups = signedGroups.drop(7)
// ── 5. Parse tagged fields ──────────────────────────────────────────── // ── 5. Parse tagged fields ────────────────────────────────────────────
var paymentHash: String? = null // ── NEW var paymentHash: String? = null
var memo: String? = null var memo: String? = null
var payee: String? = null var payeeHex: String? = null // from 'n' field (if present)
var expirySecs: Long? = null // ── NEW (null = use default) var expirySecs: Long? = null
var fallbackAddress: String? = null // ── NEW var fallbackAddress: String? = null
val routeHintRoutes = mutableListOf<List<RouteHintHop>>() val routeHintRoutes = mutableListOf<List<RouteHintHop>>()
// bech32 address HRP for this network (used for fallback address encoding) val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc"
val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc" // ── NEW
var pos = 0 var pos = 0
while (pos + 2 < taggedGroups.size) { while (pos + 2 < taggedGroups.size) {
@@ -148,9 +155,7 @@ object Bolt11Decoder {
when (type) { when (type) {
// ── NEW ───────────────────────────────────────────────────────
TAG_PAYMENT_HASH -> { TAG_PAYMENT_HASH -> {
// Fixed: 52 groups × 5 = 260 bits → 32 bytes + 4 padding bits
if (fieldGroups.size != 52) { if (fieldGroups.size != 52) {
Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)") Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)")
continue continue
@@ -170,23 +175,18 @@ object Bolt11Decoder {
continue continue
} }
val bytes = convertBits(fieldGroups, 5, 8, false) ?: 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 -> { TAG_EXPIRY -> {
// Variable-length big-endian integer in 5-bit groups
var secs = 0L var secs = 0L
for (g in fieldGroups) secs = (secs shl 5) or g.toLong() for (g in fieldGroups) secs = (secs shl 5) or g.toLong()
expirySecs = secs expirySecs = secs
} }
// ── NEW ───────────────────────────────────────────────────────
TAG_FALLBACK_ADDR -> { TAG_FALLBACK_ADDR -> {
fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp) fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp)
if (fallbackAddress == null) { if (fallbackAddress == null) Timber.w("Could not decode fallback address")
Timber.w("Could not decode fallback address")
}
} }
TAG_ROUTE_HINTS -> { TAG_ROUTE_HINTS -> {
@@ -211,16 +211,26 @@ object Bolt11Decoder {
} }
} }
// ── NEW: compute expiresAt ──────────────────────────────────────────── // ── Compute expiresAt ─────────────────────────────────────────────────
val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S) val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S)
// ── 6. Recover payee from signature if 'n' field was absent ─────────── // ── NEW: Validate / recover payee from signature ───────────────────────
if (payee == null) { //
payee = recoverPayee(hrp, signedGroups, sigGroups) // BOLT11 §Signature:
if (payee != null) Timber.d("Recovered payee pubkey from 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) { if (paymentHash == null) {
Timber.w("Invoice missing required 'p' (payment hash) field") Timber.w("Invoice missing required 'p' (payment hash) field")
return null return null
@@ -228,10 +238,12 @@ object Bolt11Decoder {
val routeHints = routeHintRoutes.flatten() val routeHints = routeHintRoutes.flatten()
Timber.d("Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " + Timber.d(
"Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " +
"payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " + "payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " +
"createdAt: $createdAt, expiresAt: $expiresAt, " + "createdAt: $createdAt, expiresAt: $expiresAt, " +
"fallback: $fallbackAddress, paymentHash: $paymentHash") "fallback: $fallbackAddress, paymentHash: $paymentHash"
)
return DecodedBolt11( return DecodedBolt11(
amountSats = amountSats, amountSats = amountSats,
@@ -245,14 +257,89 @@ 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<Int>): 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<Int>,
sigGroups: List<Int>,
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 ───────────────────────────────────────────── // ── Fallback address decoding ─────────────────────────────────────────────
/** /**
* Decodes a BOLT11 'f' (fallback address) tagged field. * Decodes a BOLT11 'f' (fallback address) tagged field.
* *
* The first 5-bit group is the address version/type: * The first 5-bit group is the address version/type:
* 0 = P2PKH → 20-byte hash → Base58Check (version byte 0x00 mainnet / 0x6f testnet) * 0 = P2PKH → 20-byte hash → Base58Check
* 1 = P2SH → 20-byte hash → Base58Check (version byte 0x05 mainnet / 0xc4 testnet) * 1 = P2SH → 20-byte hash → Base58Check
* 17 = P2WPKH → 20-byte witness program → bech32 address (witness version 0) * 17 = P2WPKH → 20-byte witness program → bech32 address (witness version 0)
* 18 = P2WSH → 32-byte witness program → bech32 address (witness version 0) * 18 = P2WSH → 32-byte witness program → bech32 address (witness version 0)
*/ */
@@ -263,29 +350,19 @@ object Bolt11Decoder {
return when (version) { return when (version) {
0 -> { 0 -> {
// P2PKH
val hash = convertBits(dataGroups, 5, 8, false) ?: return null val hash = convertBits(dataGroups, 5, 8, false) ?: return null
if (hash.size != 20) return null if (hash.size != 20) return null
val versionByte = if (addrHrp == "bc") 0x00 else 0x6f val versionByte = if (addrHrp == "bc") 0x00 else 0x6f
encodeBase58Check(versionByte, hash.toByteArray()) encodeBase58Check(versionByte, hash.toByteArray())
} }
1 -> { 1 -> {
// P2SH
val hash = convertBits(dataGroups, 5, 8, false) ?: return null val hash = convertBits(dataGroups, 5, 8, false) ?: return null
if (hash.size != 20) return null if (hash.size != 20) return null
val versionByte = if (addrHrp == "bc") 0x05 else 0xc4 val versionByte = if (addrHrp == "bc") 0x05 else 0xc4
encodeBase58Check(versionByte, hash.toByteArray()) encodeBase58Check(versionByte, hash.toByteArray())
} }
17 -> { 17 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
// P2WPKH — witness version 0, 20-byte program 18 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
// 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)
}
else -> { else -> {
Timber.w("Unknown fallback address version: $version") Timber.w("Unknown fallback address version: $version")
null 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 { private fun encodeBase58Check(versionByte: Int, payload: ByteArray): String {
val versioned = byteArrayOf(versionByte.toByte()) + payload val versioned = byteArrayOf(versionByte.toByte()) + payload
val checksum = sha256(sha256(versioned)).copyOfRange(0, 4) val checksum = sha256(sha256(versioned)).copyOfRange(0, 4)
@@ -313,24 +386,17 @@ object Bolt11Decoder {
sb.append(ALPHABET[remainder.toInt()]) sb.append(ALPHABET[remainder.toInt()])
num = quotient num = quotient
} }
// Leading zero bytes → leading '1' characters
for (byte in input) { for (byte in input) {
if (byte == 0.toByte()) sb.append('1') else break if (byte == 0.toByte()) sb.append('1') else break
} }
return sb.reverse().toString() 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( private fun encodeBech32Address(
hrp: String, hrp: String,
witnessVersion: Int, witnessVersion: Int,
dataGroups: List<Int> dataGroups: List<Int>
): String { ): String {
// data = [witnessVersion] + dataGroups
val data = listOf(witnessVersion) + dataGroups val data = listOf(witnessVersion) + dataGroups
val checksum = bech32Checksum(hrp, data) val checksum = bech32Checksum(hrp, data)
val sb = StringBuilder(hrp).append('1') val sb = StringBuilder(hrp).append('1')
@@ -359,41 +425,14 @@ object Bolt11Decoder {
return chk return chk
} }
// ── secp256k1 ECDSA public-key recovery ────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
private fun recoverPayee(
hrp: String,
signedGroups: List<Int>,
sigGroups: List<Int>
): 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 ───────────────────────────────────────────────────────────────
private fun sha256(input: ByteArray): ByteArray = private fun sha256(input: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(input) 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? { private fun parseAmountMsat(amountStr: String): Long? {
if (amountStr.isEmpty()) return 0L if (amountStr.isEmpty()) return 0L
val lastChar = amountStr.last() val lastChar = amountStr.last()
@@ -405,7 +444,7 @@ object Bolt11Decoder {
} else { } else {
val number = amountStr.toLongOrNull() ?: return null val number = amountStr.toLongOrNull() ?: return null
if (number <= 0L) return null if (number <= 0L) return null
number * 100_000_000_000L // 1 BTC = 10^11 msat number * 100_000_000_000L
} }
} }