diff --git a/nselib/bitcoin.lua b/nselib/bitcoin.lua index e22589fa4..c1a7d952a 100644 --- a/nselib/bitcoin.lua +++ b/nselib/bitcoin.lua @@ -38,6 +38,7 @@ local stdnse = require "stdnse" local string = require "string" local table = require "table" local openssl = stdnse.silent_require('openssl') +local unittest = require "unittest" _ENV = stdnse.module("bitcoin", stdnse.seeall) -- A class that supports the BitCoin network address structure @@ -625,4 +626,542 @@ Helper = { end } +-- Bitcoin address validation and classification +-- Based on Base58Check (Bitcoin Wiki), BIP173, BIP350 + +-- Base58 alphabet (Bitcoin) +local BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +-- Bech32 character set (BIP173) +local BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +-- Bech32 polymod generator coefficients (BIP173) +local BECH32_GEN = {0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} + +-- Compute double SHA256 hash +-- @param data string of bytes +-- @return string of 32 bytes, or nil, err +local function doubleSha256(data) + if not openssl then + return nil, "openssl not available" + end + local hash = openssl.digest("sha256", data) + return openssl.digest("sha256", hash) +end + +-- Decode Base58 string to big-endian byte array +-- @param str base58 encoded string +-- @return table of bytes, or nil, error message +local function decodeBase58(str) + local map = {} + for i = 1, #BASE58_ALPHABET do + map[BASE58_ALPHABET:sub(i, i)] = i - 1 + end + + -- Count leading 1s (= leading zero bytes) + local leading = 0 + for i = 1, #str do + if str:sub(i, i) ~= "1" then break end + leading = leading + 1 + end + + if #str == 0 then + return nil, "empty string" + end + + -- Decode base58 into little-endian byte array + local result = {0} + for i = 1, #str do + local c = str:sub(i, i) + local carry = map[c] + if not carry then + return nil, "invalid character" + end + for j = 1, #result do + carry = carry + result[j] * 58 + result[j] = carry & 0xff + carry = carry >> 8 + end + while carry > 0 do + result[#result + 1] = carry & 0xff + carry = carry >> 8 + end + end + + -- Reverse to big-endian + local out = {} + for i = #result, 1, -1 do + out[#out + 1] = result[i] + end + + -- Prepend leading zeros + for i = 1, leading do + table.insert(out, 1, 0) + end + + return out +end + +-- Validate Base58Check encoded string +-- @param str base58 encoded string +-- @return table with payload (byte array), or nil, error +local function decodeBase58Check(str) + if not openssl then + return nil, "openssl not available" + end + + local decoded, err = decodeBase58(str) + if not decoded then + return nil, err + end + + if #decoded < 5 then + return nil, "too short" + end + + -- Split into payload (all but last 4) and checksum (last 4) + local payloadLen = #decoded - 4 + local payloadBytes = {} + for i = 1, payloadLen do + payloadBytes[i] = decoded[i] + end + + -- Build string and verify double-SHA256 checksum + local payloadStr = string.char(table.unpack(payloadBytes, 1, payloadLen)) + local hash = doubleSha256(payloadStr) + + for i = 1, 4 do + if hash:byte(i) ~= decoded[payloadLen + i] then + return nil, "invalid checksum" + end + end + + return { payload = payloadBytes } +end + +-- Expand HRP for Bech32 checksum (BIP173) +-- @param hrp string +-- @return table of 5-bit values +local function hrpExpand(hrp) + local ret = {} + for i = 1, #hrp do + ret[#ret + 1] = hrp:byte(i) >> 5 + end + ret[#ret + 1] = 0 + for i = 1, #hrp do + ret[#ret + 1] = hrp:byte(i) & 31 + end + return ret +end + +-- Compute Bech32 polymod (BIP173/BIP350) +-- @param values table of 5-bit values +-- @return integer checksum +local function bech32Polymod(values) + local chk = 1 + for _, v in ipairs(values) do + local top = chk >> 25 + chk = ((chk & 0x1ffffff) << 5) ~ v + for i = 1, 5 do + if (top >> (i - 1)) & 1 == 1 then + chk = chk ~ BECH32_GEN[i] + end + end + end + return chk +end + +-- Convert between bit widths +-- @param data table of source values +-- @param fromBits source bit width +-- @param toBits target bit width +-- @param pad whether to allow padding +-- @return table of converted values, or nil, error +local function convertBits(data, fromBits, toBits, pad) + local ret = {} + local acc = 0 + local bits = 0 + local maxv = (1 << toBits) - 1 + local maxAcc = (1 << (fromBits + toBits)) - 1 + for _, v in ipairs(data) do + if v < 0 or (v >> fromBits) ~= 0 then + return nil, "invalid value for bit width" + end + acc = ((acc << fromBits) | v) & maxAcc + bits = bits + fromBits + while bits >= toBits do + bits = bits - toBits + ret[#ret + 1] = (acc >> bits) & maxv + end + end + if pad then + if bits > 0 then + ret[#ret + 1] = (acc << (toBits - bits)) & maxv + end + else + if bits >= fromBits or ((acc << (toBits - bits)) & maxv) ~= 0 then + return nil, "non-zero padding" + end + end + return ret +end + +-- Decode Bech32/Bech32m string (BIP173/BIP350) +-- @param str bech32 encoded string +-- @return table with hrp, witnessVersion, witnessProgram, encoding, or nil, error +local function decodeBech32(str) + -- Find separator '1' + local sep = str:find("1", 2, true) + if not sep then + return nil, "missing separator" + end + if sep < 2 or sep > #str - 7 then + return nil, "invalid separator position" + end + + local hrp = str:sub(1, sep - 1):lower() + local dataPart = str:sub(sep + 1) + + if #dataPart < 7 then + return nil, "data part too short" + end + + -- Check for mixed case + local lower = dataPart:lower() + local upper = dataPart:upper() + if dataPart ~= lower and dataPart ~= upper then + return nil, "mixed case" + end + dataPart = lower + + -- Decode data characters to 5-bit values + local values = {} + for i = 1, #dataPart do + local pos = BECH32_CHARSET:find(dataPart:sub(i, i), 1, true) + if not pos then + return nil, "invalid character" + end + values[#values + 1] = pos - 1 + end + + -- Verify checksum + local checksumInput = hrpExpand(hrp) + for _, v in ipairs(values) do + checksumInput[#checksumInput + 1] = v + end + local polymod = bech32Polymod(checksumInput) + + local encoding + if polymod == 1 then + encoding = "bech32" + elseif polymod == 0x2bc830a3 then + encoding = "bech32m" + else + return nil, "invalid checksum" + end + + -- Extract witness version and program (5-bit groups) + local witnessVersion = values[1] + if witnessVersion > 16 then + return nil, "invalid witness version" + end + + local witnessProgram5 = {} + for i = 2, #values - 6 do + witnessProgram5[#witnessProgram5 + 1] = values[i] + end + + -- Convert witness program from 5-bit to 8-bit + local witnessProgram8, err = convertBits(witnessProgram5, 5, 8, false) + if not witnessProgram8 then + return nil, "invalid witness program: " .. err + end + + -- Validate based on witness version + if witnessVersion == 0 then + if encoding ~= "bech32" then + return nil, "v0 witness must use Bech32, not Bech32m" + end + if #witnessProgram8 ~= 20 and #witnessProgram8 ~= 32 then + return nil, "invalid witness program length for v0" + end + elseif witnessVersion >= 1 and witnessVersion <= 16 then + if encoding ~= "bech32m" then + return nil, string.format("v%d witness must use Bech32m", witnessVersion) + end + if #witnessProgram8 < 2 or #witnessProgram8 > 40 then + return nil, "invalid witness program length for v1+" + end + end + + return { + hrp = hrp, + witnessVersion = witnessVersion, + witnessProgram = witnessProgram8, + encoding = encoding, + } +end + +--- Quick regex-based address candidate detection +-- @param value string to check +-- @return table with candidate, family, probable_network, probable_type +looks_like_address = function(value) + if type(value) ~= "string" or #value == 0 then + return { candidate = false } + end + + -- Bech32 patterns + if value:match("^[Bb][Cc]1") then + return { candidate = true, family = "bech32", probable_network = "mainnet", probable_type = "segwit_or_taproot" } + end + if value:match("^[Tt][Bb]1") then + return { candidate = true, family = "bech32", probable_network = "testnet", probable_type = "segwit_or_taproot" } + end + if value:match("^[Bb][Cc][Rr][Tt]1") then + return { candidate = true, family = "bech32", probable_network = "regtest", probable_type = "segwit_or_taproot" } + end + + -- Base58Check patterns + local first = value:sub(1, 1) + if first == "1" then + return { candidate = true, family = "base58", probable_network = "mainnet", probable_type = "p2pkh" } + end + if first == "3" then + return { candidate = true, family = "base58", probable_network = "mainnet", probable_type = "p2sh" } + end + if first == "m" or first == "n" then + return { candidate = true, family = "base58", probable_network = "testnet", probable_type = "p2pkh" } + end + if first == "2" then + return { candidate = true, family = "base58", probable_network = "testnet", probable_type = "p2sh" } + end + + -- WIF patterns + if first == "5" or first == "K" or first == "L" then + return { candidate = true, family = "base58", probable_network = "mainnet", probable_type = "wif" } + end + if first == "9" or first == "c" then + return { candidate = true, family = "base58", probable_network = "testnet", probable_type = "wif" } + end + + -- Extended key patterns + if value:match("^[xX][pP][uU][bB]") then + return { candidate = true, family = "base58", probable_network = "mainnet", probable_type = "xpub" } + end + if value:match("^[xX][pP][rR][vV]") then + return { candidate = true, family = "base58", probable_network = "mainnet", probable_type = "xprv" } + end + if value:match("^[tT][pP][uU][bB]") then + return { candidate = true, family = "base58", probable_network = "testnet", probable_type = "xpub" } + end + if value:match("^[tT][pP][rR][vV]") then + return { candidate = true, family = "base58", probable_network = "testnet", probable_type = "xprv" } + end + + return { candidate = false } +end + +--- Full cryptographic address validation +-- @param value string to validate +-- @return table with valid, network, type, encoding, witnessVersion, reason +validate_address = function(value) + if type(value) ~= "string" or #value == 0 then + return { valid = false, reason = "empty or non-string" } + end + + -- Try Bech32/Bech32m for addresses starting with known HRPs + local lower = value:lower() + if lower:sub(1, 4) == "bcrt" or lower:sub(1, 2) == "bc" or lower:sub(1, 2) == "tb" then + local result, err = decodeBech32(value) + if not result then + return { valid = false, encoding = "bech32", reason = err } + end + local network + if result.hrp == "bc" then + network = "mainnet" + elseif result.hrp == "tb" then + network = "testnet" + elseif result.hrp == "bcrt" then + network = "regtest" + else + network = "unknown" + end + local addrType + if result.witnessVersion == 0 then + if #result.witnessProgram == 20 then + addrType = "p2wpkh" + elseif #result.witnessProgram == 32 then + addrType = "p2wsh" + else + addrType = "segwit_v0" + end + elseif result.witnessVersion == 1 then + addrType = "p2tr" + else + addrType = "witness_v1_plus" + end + return { + valid = true, + network = network, + type = addrType, + encoding = result.encoding, + witnessVersion = result.witnessVersion, + reason = "ok", + } + end + + -- Try Base58Check + local result, err = decodeBase58Check(value) + if not result then + return { valid = false, encoding = "base58check", reason = err or "invalid" } + end + + local payload = result.payload + if #payload < 1 then + return { valid = false, encoding = "base58check", reason = "empty payload" } + end + + -- Check for 4-byte version prefixes (extended keys) + if #payload >= 4 then + local v4 = (payload[1] << 24) | (payload[2] << 16) | (payload[3] << 8) | payload[4] + if v4 == 0x0488B21E then + return { valid = true, network = "mainnet", type = "xpub", encoding = "base58check", reason = "ok" } + end + if v4 == 0x0488ADE4 then + return { valid = true, network = "mainnet", type = "xprv", encoding = "base58check", reason = "ok" } + end + if v4 == 0x043587CF then + return { valid = true, network = "testnet", type = "xpub", encoding = "base58check", reason = "ok" } + end + if v4 == 0x04358394 then + return { valid = true, network = "testnet", type = "xprv", encoding = "base58check", reason = "ok" } + end + end + + -- Check single-byte version prefixes + local v = payload[1] + if v == 0x00 then + return { valid = true, network = "mainnet", type = "p2pkh", encoding = "base58check", reason = "ok" } + end + if v == 0x05 then + return { valid = true, network = "mainnet", type = "p2sh", encoding = "base58check", reason = "ok" } + end + if v == 0x6f then + return { valid = true, network = "testnet", type = "p2pkh", encoding = "base58check", reason = "ok" } + end + if v == 0xc4 then + return { valid = true, network = "testnet", type = "p2sh", encoding = "base58check", reason = "ok" } + end + if v == 0x80 then + local net = (value:sub(1, 1) == "9" or value:sub(1, 1) == "c") and "testnet" or "mainnet" + return { valid = true, network = net, type = "wif", encoding = "base58check", reason = "ok" } + end + + return { valid = true, network = "unknown", type = "unknown", encoding = "base58check", reason = "unknown version byte" } +end + +--- High-level address classification uses regex detection then full validation +-- @param value string to classify +-- @return table with valid, network, type, encoding, reason +classify_address = function(value) + local quick = looks_like_address(value) + if not quick.candidate then + return { valid = false, reason = "not a recognized address format" } + end + return validate_address(value) +end + +if not unittest.testing() then + return _ENV +end + +-- Test vectors (public/synthetic - no real keys with funds) +test_suite = unittest.TestSuite:new() + +-- Mainnet Base58Check valid addresses +local r1 = classify_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") +test_suite:add_test(unittest.is_true(r1.valid), "mainnet P2PKH (genesis) valid") +test_suite:add_test(unittest.equal(r1.network, "mainnet"), "genesis network mainnet") +test_suite:add_test(unittest.equal(r1.type, "p2pkh"), "genesis type p2pkh") + +local r2 = classify_address("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy") +test_suite:add_test(unittest.is_true(r2.valid), "mainnet P2SH valid") +test_suite:add_test(unittest.equal(r2.network, "mainnet"), "P2SH network mainnet") +test_suite:add_test(unittest.equal(r2.type, "p2sh"), "P2SH type p2sh") + +-- Mainnet Bech32m P2TR (BIP350) +local r3 = classify_address("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0") +test_suite:add_test(unittest.is_true(r3.valid), "mainnet P2TR (BIP350) valid") +test_suite:add_test(unittest.equal(r3.network, "mainnet"), "P2TR network mainnet") +test_suite:add_test(unittest.equal(r3.type, "p2tr"), "P2TR type") + +-- Testnet Bech32m P2TR (BIP350) +local r4 = classify_address("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c") +test_suite:add_test(unittest.is_true(r4.valid), "testnet P2TR (BIP350) valid") +test_suite:add_test(unittest.equal(r4.network, "testnet"), "testnet P2TR network") +test_suite:add_test(unittest.equal(r4.type, "p2tr"), "testnet P2TR type") + +-- Mainnet Bech32 (BIP173 P2WPKH) +local r5 = classify_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") +test_suite:add_test(unittest.is_true(r5.valid), "mainnet P2WPKH (BIP173) valid") +test_suite:add_test(unittest.equal(r5.network, "mainnet"), "P2WPKH network mainnet") +test_suite:add_test(unittest.equal(r5.type, "p2wpkh"), "P2WPKH type") + +-- Mainnet Bech32m v1 P2WSH (BIP350) +local r6 = classify_address("bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y") +test_suite:add_test(unittest.is_true(r6.valid), "mainnet v1 P2WSH (BIP350) valid") +test_suite:add_test(unittest.equal(r6.network, "mainnet"), "v1 P2WSH network mainnet") + +-- Testnet Bech32 (BIP173 P2WSH) +local r7 = classify_address("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7") +test_suite:add_test(unittest.is_true(r7.valid), "testnet P2WSH (BIP173) valid") +test_suite:add_test(unittest.equal(r7.network, "testnet"), "testnet P2WSH network") +test_suite:add_test(unittest.equal(r7.type, "p2wsh"), "testnet P2WSH type") + +-- Mainnet Bech32m v2 (BIP350) +local r8 = classify_address("bc1zw508d6qejxtdg4y5r3zarvaryvaxxpcs") +test_suite:add_test(unittest.is_true(r8.valid), "mainnet v2 Bech32m (BIP350) valid") +test_suite:add_test(unittest.equal(r8.network, "mainnet"), "v2 network") +test_suite:add_test(unittest.equal(r8.type, "witness_v1_plus"), "v2 type") + +-- Invalid addresses +local r9 = validate_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb") +test_suite:add_test(unittest.is_false(r9.valid), "broken Base58 checksum invalid") + +local r10 = validate_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5") +test_suite:add_test(unittest.is_false(r10.valid), "broken Bech32 checksum invalid") + +local r11 = validate_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3tQ") +test_suite:add_test(unittest.is_false(r11.valid), "mixed case Bech32 invalid") + +local r12 = validate_address("bc1q") +test_suite:add_test(unittest.is_false(r12.valid), "too short Bech32 invalid") + +-- Invalid Bech32m (v0 address decoded as Bech32m fails, but here we use a BIP350 invalid test) +local r12b = validate_address("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqh2y7hd") +test_suite:add_test(unittest.is_false(r12b.valid), "Bech32m invalid checksum") + +-- Regex detection tests +local r13 = looks_like_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") +test_suite:add_test(unittest.is_true(r13.candidate), "looks_like mainnet P2PKH") +test_suite:add_test(unittest.equal(r13.family, "base58"), "looks_like family base58") +test_suite:add_test(unittest.equal(r13.probable_network, "mainnet"), "looks_like network mainnet") +test_suite:add_test(unittest.equal(r13.probable_type, "p2pkh"), "looks_like type p2pkh") + +local r14 = looks_like_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") +test_suite:add_test(unittest.is_true(r14.candidate), "looks_like Bech32") +test_suite:add_test(unittest.equal(r14.family, "bech32"), "looks_like family bech32") + +local r15 = looks_like_address("random text that is not an address") +test_suite:add_test(unittest.is_false(r15.candidate), "random text not a candidate") + +-- Regtest detection +local r16 = looks_like_address("bcrt1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") +test_suite:add_test(unittest.is_true(r16.candidate), "looks_like regtest Bech32") +test_suite:add_test(unittest.equal(r16.probable_network, "regtest"), "looks_like regtest network") + +-- classify_address on non-address +local r17 = classify_address("not an address") +test_suite:add_test(unittest.is_false(r17.valid), "classify non-address invalid") + return _ENV;