feat: add checksum and signature validation to Bolt11Decoder
This commit is contained in:
@@ -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<Char, Long> = 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<List<RouteHintHop>>()
|
||||
|
||||
// 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<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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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<Int>, 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<Int>
|
||||
): 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<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 ───────────────────────────────────────────────────────────────
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user