diff --git a/src/chains/bitcoin.ts b/src/chains/bitcoin.ts index 8e848c3..31cfe31 100644 --- a/src/chains/bitcoin.ts +++ b/src/chains/bitcoin.ts @@ -1,13 +1,72 @@ +import { createHash } from "node:crypto"; + import { decodeBase58 } from "../core/base58.js"; import { Chain } from "../core/chain.js"; import { InvalidAddressError } from "../core/errors.js"; +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] as const; +const BECH32_CONST = 1; +const BECH32M_CONST = 0x2bc830a3; + // BIP-173 charset, which drops 1, b, i and o so they cannot be misread. An address // is all-lowercase or all-uppercase — uppercase is what QR encoders emit — and mixed // case is invalid, so the two cases are separate alternatives rather than a flag. const BECH32_ADDRESS = /^(bc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{39,59}|BC1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{39,59})$/; +function doubleSha256(data: Uint8Array): Uint8Array { + const first = createHash("sha256").update(data).digest(); + return createHash("sha256").update(first).digest(); +} + +function validateBase58Check(decoded: Uint8Array): boolean { + if (decoded.length !== 25) return false; + if (decoded[0] !== 0x00 && decoded[0] !== 0x05) return false; + const payload = decoded.subarray(0, 21); + const checksum = decoded.subarray(21, 25); + const computed = doubleSha256(payload).subarray(0, 4); + return ( + checksum[0] === computed[0] && + checksum[1] === computed[1] && + checksum[2] === computed[2] && + checksum[3] === computed[3] + ); +} + +function bech32Polymod(values: readonly number[]): number { + let chk = 1; + for (const value of values) { + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < 5; i++) { + if ((top >>> i) & 1) { + chk ^= BECH32_GENERATOR[i] ?? 0; + } + } + } + return chk; +} + +function validateBech32(address: string): boolean { + if (!BECH32_ADDRESS.test(address)) return false; + const lower = address.toLowerCase(); + const dataPart = lower.slice(3); + const values: number[] = [3, 3, 0, 2, 3]; + for (let i = 0; i < dataPart.length; i++) { + const d = BECH32_CHARSET.indexOf(dataPart[i] ?? ""); + if (d === -1) return false; + values.push(d); + } + + const poly = bech32Polymod(values); + const version = values[5]; + if (version === 0) { + return poly === BECH32_CONST; + } + return poly === BECH32M_CONST; +} + export class Bitcoin extends Chain { static readonly key = "bitcoin" as const; readonly type = "utxo" as const; @@ -18,16 +77,20 @@ export class Bitcoin extends Chain { readonly caip2 = "bip122:000000000019d6689c085ae165831e93"; /** + * Validates both legacy Base58Check and SegWit Bech32/Bech32m addresses. + * * A legacy address is Base58Check: a version byte (0x00 pay-to-pubkey-hash, * 0x05 pay-to-script-hash), a 20-byte hash and a 4-byte checksum, 25 bytes in - * all. Decoding is the check the format needs, because a character-length - * window lets any 32-byte base58 key through and Solana's System Program is - * exactly that. The checksum stays unchecked: this is a format check. + * all. The 4-byte checksum is verified against double SHA-256 of the payload. + * + * Native SegWit and Taproot addresses are Bech32/Bech32m encoded under the + * `bc` prefix and verified via polymod checksum calculation (BIP-173 for + * witness version 0, BIP-350 for witness versions 1-16). */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 35); - const legacy = decoded?.length === 25 && (decoded[0] === 0x00 || decoded[0] === 0x05); - if (!legacy && !BECH32_ADDRESS.test(address)) { + const legacy = decoded ? validateBase58Check(decoded) : false; + if (!legacy && !validateBech32(address)) { throw new InvalidAddressError(this.key, address); } return address; diff --git a/test/unit/chains.test.ts b/test/unit/chains.test.ts index 841dd7d..7240d2e 100644 --- a/test/unit/chains.test.ts +++ b/test/unit/chains.test.ts @@ -517,6 +517,24 @@ describe("Bitcoin address validation", () => { InvalidAddressError, ); }); + + it("rejects corrupted Base58Check checksums on legacy addresses", () => { + // Valid P2PKH address: 14zMkTgaVXJcxdh4JdWi29MLRR44iUSG9W + expect(bitcoin.assertAddress("14zMkTgaVXJcxdh4JdWi29MLRR44iUSG9W")).toBeTruthy(); + // 1-character corruption at the end changes stored checksum from d1093281 to d1093282 + expect(() => bitcoin.assertAddress("14zMkTgaVXJcxdh4JdWi29MLRR44iUSG9X")).toThrow( + InvalidAddressError, + ); + }); + + it("rejects corrupted bech32 checksums", () => { + // Valid bech32 address: bc1qaxm5p35r3yl25rdh5ex0j6wx33peht9r735x90 + expect(bitcoin.assertAddress("bc1qaxm5p35r3yl25rdh5ex0j6wx33peht9r735x90")).toBeTruthy(); + // 1-character corruption at the end changes polymod from 1 to 0x0e + expect(() => bitcoin.assertAddress("bc1qaxm5p35r3yl25rdh5ex0j6wx33peht9r735x9q")).toThrow( + InvalidAddressError, + ); + }); }); describe("Litecoin address validation", () => {