diff --git a/package-lock.json b/package-lock.json index 3bb77e07..f6a87e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "jsonwebtoken": "9.0.3", "jsqr": "^1.4.0", "jsrsasign": "^11.1.0", - "kbpgp": "2.1.15", + "kbpgp": "^2.1.17", "libbzip2-wasm": "0.0.4", "libyara-wasm": "^1.2.1", "lodash": "^4.17.21", @@ -12459,9 +12459,9 @@ } }, "node_modules/kbpgp": { - "version": "2.1.15", - "resolved": "https://registry.npmjs.org/kbpgp/-/kbpgp-2.1.15.tgz", - "integrity": "sha512-iFdQT+m2Mi2DB14kEFydF2joNe9x3E2VZCGZUt7UXsiZnQx5TtSl4KofP7EPtjHvf7weCxNKlEPSYiiCNMZ2jA==", + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/kbpgp/-/kbpgp-2.1.17.tgz", + "integrity": "sha512-pnjH7amyg6dZLXyF42BKbCTST0l0r1ErunqtFRrJCkHkGJb83cZZmx1pnqNFr+d/ls+5gvcHrZLPfUG5q7oRYw==", "license": "BSD-3-Clause", "dependencies": { "bn": "^1.0.5", diff --git a/package.json b/package.json index b901cad5..0e23f898 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "jsonwebtoken": "9.0.3", "jsqr": "^1.4.0", "jsrsasign": "^11.1.0", - "kbpgp": "2.1.15", + "kbpgp": "^2.1.17", "libbzip2-wasm": "0.0.4", "libyara-wasm": "^1.2.1", "lodash": "^4.17.21", diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index a9c381d7..eae86374 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -177,7 +177,7 @@ class Utils { */ static printable(str, preserveWs=false, onlyAscii=false) { if (onlyAscii) { - return str.replace(/[^\x20-\x7f]/g, "."); + return str.replace(/[^\x20-\x7e]/g, "."); } // eslint-disable-next-line no-misleading-character-class diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 434c8bb6..aac00ca1 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -26,6 +26,8 @@ "From Base45", "To Base58", "From Base58", + "To Bech32", + "From Bech32", "To Base62", "From Base62", "To Base64", diff --git a/src/core/lib/Bech32.mjs b/src/core/lib/Bech32.mjs new file mode 100644 index 00000000..6b87a142 --- /dev/null +++ b/src/core/lib/Bech32.mjs @@ -0,0 +1,371 @@ +/** + * Pure JavaScript implementation of Bech32 and Bech32m encoding. + * + * Bech32 is defined in BIP-0173: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki + * Bech32m is defined in BIP-0350: https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki + * + * @author Medjedtxm + * @copyright Crown Copyright 2025 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError.mjs"; + +/** Bech32 character set (32 characters, excludes 1, b, i, o) */ +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +/** Reverse lookup table for decoding */ +const CHARSET_REV = {}; +for (let i = 0; i < CHARSET.length; i++) { + CHARSET_REV[CHARSET[i]] = i; +} + +/** Checksum constant for Bech32 (BIP-0173) */ +const BECH32_CONST = 1; + +/** Checksum constant for Bech32m (BIP-0350) */ +const BECH32M_CONST = 0x2bc830a3; + +/** Generator polynomial coefficients for checksum */ +const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + +/** + * Compute the polymod checksum + * @param {number[]} values - Array of 5-bit values + * @returns {number} - Checksum value + */ +function polymod(values) { + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) { + if ((top >> i) & 1) { + chk ^= GENERATOR[i]; + } + } + } + return chk; +} + +/** + * Expand HRP for checksum computation + * @param {string} hrp - Human-readable part (lowercase) + * @returns {number[]} - Expanded values + */ +function hrpExpand(hrp) { + const result = []; + for (let i = 0; i < hrp.length; i++) { + result.push(hrp.charCodeAt(i) >> 5); + } + result.push(0); + for (let i = 0; i < hrp.length; i++) { + result.push(hrp.charCodeAt(i) & 31); + } + return result; +} + +/** + * Verify checksum of a Bech32/Bech32m string + * @param {string} hrp - Human-readable part (lowercase) + * @param {number[]} data - Data including checksum (5-bit values) + * @param {string} encoding - "Bech32" or "Bech32m" + * @returns {boolean} - True if checksum is valid + */ +function verifyChecksum(hrp, data, encoding) { + const constant = encoding === "Bech32m" ? BECH32M_CONST : BECH32_CONST; + return polymod(hrpExpand(hrp).concat(data)) === constant; +} + +/** + * Create checksum for Bech32/Bech32m encoding + * @param {string} hrp - Human-readable part (lowercase) + * @param {number[]} data - Data values (5-bit) + * @param {string} encoding - "Bech32" or "Bech32m" + * @returns {number[]} - 6 checksum values + */ +function createChecksum(hrp, data, encoding) { + const constant = encoding === "Bech32m" ? BECH32M_CONST : BECH32_CONST; + const values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]); + const mod = polymod(values) ^ constant; + const result = []; + for (let i = 0; i < 6; i++) { + result.push((mod >> (5 * (5 - i))) & 31); + } + return result; +} + +/** + * Convert 8-bit bytes to 5-bit words + * @param {number[]|Uint8Array} data - Input bytes + * @returns {number[]} - 5-bit words + */ +export function toWords(data) { + let value = 0; + let bits = 0; + const result = []; + + for (let i = 0; i < data.length; i++) { + value = (value << 8) | data[i]; + bits += 8; + + while (bits >= 5) { + bits -= 5; + result.push((value >> bits) & 31); + } + } + + // Pad remaining bits + if (bits > 0) { + result.push((value << (5 - bits)) & 31); + } + + return result; +} + +/** + * Convert 5-bit words to 8-bit bytes + * @param {number[]} words - 5-bit words + * @returns {number[]} - Output bytes + */ +export function fromWords(words) { + let value = 0; + let bits = 0; + const result = []; + + for (let i = 0; i < words.length; i++) { + value = (value << 5) | words[i]; + bits += 5; + + while (bits >= 8) { + bits -= 8; + result.push((value >> bits) & 255); + } + } + + // Check for invalid padding per BIP-0173 + // Condition 1: Cannot have 5+ bits remaining (would indicate incomplete byte) + if (bits >= 5) { + throw new OperationError("Invalid padding: too many bits remaining"); + } + // Condition 2: Remaining padding bits must all be zero + if (bits > 0) { + const paddingValue = (value << (8 - bits)) & 255; + if (paddingValue !== 0) { + throw new OperationError("Invalid padding: non-zero bits in padding"); + } + } + + return result; +} + +/** + * Encode data to Bech32/Bech32m string + * + * @param {string} hrp - Human-readable part + * @param {number[]|Uint8Array} data - Data bytes to encode + * @param {string} encoding - "Bech32" or "Bech32m" + * @param {boolean} segwit - If true, treat first byte as witness version (for Bitcoin SegWit) + * @returns {string} - Encoded Bech32/Bech32m string + */ +export function encode(hrp, data, encoding = "Bech32", segwit = false) { + // Validate HRP + if (!hrp || hrp.length === 0) { + throw new OperationError("Human-Readable Part (HRP) cannot be empty."); + } + + // Check HRP characters (ASCII 33-126) + for (let i = 0; i < hrp.length; i++) { + const c = hrp.charCodeAt(i); + if (c < 33 || c > 126) { + throw new OperationError(`HRP contains invalid character at position ${i}. Only printable ASCII characters (33-126) are allowed.`); + } + } + + // Convert HRP to lowercase + const hrpLower = hrp.toLowerCase(); + + let words; + if (segwit && data.length >= 2) { + // SegWit encoding: first byte is witness version (0-16), rest is witness program + const witnessVersion = data[0]; + if (witnessVersion > 16) { + throw new OperationError(`Invalid witness version: ${witnessVersion}. Must be 0-16.`); + } + const witnessProgram = Array.prototype.slice.call(data, 1); + + // Validate witness program length per BIP-0141 + if (witnessProgram.length < 2 || witnessProgram.length > 40) { + throw new OperationError(`Invalid witness program length: ${witnessProgram.length}. Must be 2-40 bytes.`); + } + if (witnessVersion === 0 && witnessProgram.length !== 20 && witnessProgram.length !== 32) { + throw new OperationError(`Invalid witness program length for v0: ${witnessProgram.length}. Must be 20 or 32 bytes.`); + } + + // Witness version is kept as single 5-bit value, program is converted + words = [witnessVersion].concat(toWords(witnessProgram)); + } else { + // Generic encoding: convert all bytes to 5-bit words + words = toWords(data); + } + + // Create checksum + const checksum = createChecksum(hrpLower, words, encoding); + + // Build result string + let result = hrpLower + "1"; + for (const w of words.concat(checksum)) { + result += CHARSET[w]; + } + + // Check maximum length (90 characters) + if (result.length > 90) { + throw new OperationError(`Encoded string exceeds maximum length of 90 characters (got ${result.length}). Consider using smaller input data.`); + } + + return result; +} + +/** + * Decode a Bech32/Bech32m string + * + * @param {string} str - Bech32/Bech32m encoded string + * @param {string} encoding - "Bech32", "Bech32m", or "Auto-detect" + * @returns {{hrp: string, data: number[]}} - Decoded HRP and data bytes + */ +export function decode(str, encoding = "Auto-detect") { + // Check for empty input + if (!str || str.length === 0) { + throw new OperationError("Input cannot be empty."); + } + + // Check maximum length + if (str.length > 90) { + throw new OperationError(`Invalid Bech32 string: exceeds maximum length of 90 characters (got ${str.length}).`); + } + + // Check for mixed case + const hasUpper = /[A-Z]/.test(str); + const hasLower = /[a-z]/.test(str); + if (hasUpper && hasLower) { + throw new OperationError("Invalid Bech32 string: mixed case is not allowed. Use all uppercase or all lowercase."); + } + + // Convert to lowercase for processing + str = str.toLowerCase(); + + // Find separator (last occurrence of '1') + const sepIndex = str.lastIndexOf("1"); + if (sepIndex === -1) { + throw new OperationError("Invalid Bech32 string: no separator '1' found."); + } + + if (sepIndex === 0) { + throw new OperationError("Invalid Bech32 string: Human-Readable Part (HRP) cannot be empty."); + } + + if (sepIndex + 7 > str.length) { + throw new OperationError("Invalid Bech32 string: data part is too short (minimum 6 characters for checksum)."); + } + + // Extract HRP and data part + const hrp = str.substring(0, sepIndex); + const dataPart = str.substring(sepIndex + 1); + + // Validate HRP characters + for (let i = 0; i < hrp.length; i++) { + const c = hrp.charCodeAt(i); + if (c < 33 || c > 126) { + throw new OperationError(`HRP contains invalid character at position ${i}.`); + } + } + + // Decode data characters to 5-bit values + const data = []; + for (let i = 0; i < dataPart.length; i++) { + const c = dataPart[i]; + if (CHARSET_REV[c] === undefined) { + throw new OperationError(`Invalid character '${c}' at position ${sepIndex + 1 + i}.`); + } + data.push(CHARSET_REV[c]); + } + + // Verify checksum + let usedEncoding; + if (encoding === "Bech32") { + if (!verifyChecksum(hrp, data, "Bech32")) { + throw new OperationError("Invalid Bech32 checksum."); + } + usedEncoding = "Bech32"; + } else if (encoding === "Bech32m") { + if (!verifyChecksum(hrp, data, "Bech32m")) { + throw new OperationError("Invalid Bech32m checksum."); + } + usedEncoding = "Bech32m"; + } else { + // Auto-detect: try Bech32 first, then Bech32m + if (verifyChecksum(hrp, data, "Bech32")) { + usedEncoding = "Bech32"; + } else if (verifyChecksum(hrp, data, "Bech32m")) { + usedEncoding = "Bech32m"; + } else { + throw new OperationError("Invalid Bech32/Bech32m string: checksum verification failed."); + } + } + + // Remove checksum (last 6 values) + const words = data.slice(0, data.length - 6); + + // Check if this is likely a SegWit address (Bitcoin, Litecoin, etc.) + // For SegWit, the first 5-bit word is the witness version (0-16) + // and should be extracted separately, not bit-converted with the rest + const segwitHrps = ["bc", "tb", "ltc", "tltc", "bcrt"]; + const couldBeSegWit = segwitHrps.includes(hrp) && words.length > 0 && words[0] <= 16; + + let bytes; + let witnessVersion = null; + + if (couldBeSegWit) { + // Try SegWit decode first + try { + witnessVersion = words[0]; + const programWords = words.slice(1); + const programBytes = fromWords(programWords); + + // Validate SegWit witness program length (20 or 32 bytes for v0, 2-40 for others) + const validV0 = witnessVersion === 0 && (programBytes.length === 20 || programBytes.length === 32); + const validOther = witnessVersion !== 0 && programBytes.length >= 2 && programBytes.length <= 40; + + if (validV0 || validOther) { + // Valid SegWit address + bytes = [witnessVersion, ...programBytes]; + } else { + // Not valid SegWit, fall back to generic decode + witnessVersion = null; + bytes = fromWords(words); + } + } catch (e) { + // SegWit decode failed, try generic decode + witnessVersion = null; + try { + bytes = fromWords(words); + } catch (e2) { + throw new OperationError(`Failed to decode data: ${e2.message}`); + } + } + } else { + // Generic Bech32: convert all words + try { + bytes = fromWords(words); + } catch (e) { + throw new OperationError(`Failed to decode data: ${e.message}`); + } + } + + return { + hrp: hrp, + data: bytes, + encoding: usedEncoding, + witnessVersion: witnessVersion + }; +} diff --git a/src/core/lib/JA4.mjs b/src/core/lib/JA4.mjs index f600f4d8..58422bca 100644 --- a/src/core/lib/JA4.mjs +++ b/src/core/lib/JA4.mjs @@ -91,9 +91,7 @@ export function toJA4(bytes) { let alpn = "00"; for (const ext of tlsr.handshake.value.extensions.value) { if (ext.type.value === "application_layer_protocol_negotiation") { - alpn = parseFirstALPNValue(ext.value.data); - alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1); - if (alpn.charCodeAt(0) > 127) alpn = "99"; + alpn = alpnFingerprint(parseFirstALPNValue(ext.value.data)); break; } } @@ -212,9 +210,7 @@ export function toJA4S(bytes) { let alpn = "00"; for (const ext of tlsr.handshake.value.extensions.value) { if (ext.type.value === "application_layer_protocol_negotiation") { - alpn = parseFirstALPNValue(ext.value.data); - alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1); - if (alpn.charCodeAt(0) > 127) alpn = "99"; + alpn = alpnFingerprint(parseFirstALPNValue(ext.value.data)); break; } } @@ -262,3 +258,33 @@ function tlsVersionMapper(version) { default: return "00"; // Unknown } } + +/** + * Checks if a byte is ASCII alphanumeric (0-9, A-Z, a-z). + * @param {number} byte + * @returns {boolean} + */ +function isAlphanumeric(byte) { + return (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5A) || + (byte >= 0x61 && byte <= 0x7A); +} + +/** + * Computes the 2-character ALPN fingerprint from raw ALPN bytes. + * If both first and last bytes are ASCII alphanumeric, returns their characters. + * Otherwise, returns first hex digit of first byte + last hex digit of last byte. + * @param {Uint8Array|null} rawBytes + * @returns {string} + */ +function alpnFingerprint(rawBytes) { + if (!rawBytes || rawBytes.length === 0) return "00"; + const firstByte = rawBytes[0]; + const lastByte = rawBytes[rawBytes.length - 1]; + if (isAlphanumeric(firstByte) && isAlphanumeric(lastByte)) { + return String.fromCharCode(firstByte) + String.fromCharCode(lastByte); + } + const firstHex = firstByte.toString(16).padStart(2, "0"); + const lastHex = lastByte.toString(16).padStart(2, "0"); + return firstHex[0] + lastHex[1]; +} diff --git a/src/core/lib/TLS.mjs b/src/core/lib/TLS.mjs index 6373bfa2..eaf661a8 100644 --- a/src/core/lib/TLS.mjs +++ b/src/core/lib/TLS.mjs @@ -863,15 +863,15 @@ export function parseHighestSupportedVersion(bytes) { } /** - * Parses the application_layer_protocol_negotiation extension and returns the first value. + * Parses the application_layer_protocol_negotiation extension and returns the first value as raw bytes. * @param {Uint8Array} bytes - * @returns {number} + * @returns {Uint8Array|null} */ export function parseFirstALPNValue(bytes) { const s = new Stream(bytes); const alpnExtLen = s.readInt(2); - if (alpnExtLen < 3) return "00"; + if (alpnExtLen < 2) return null; const strLen = s.readInt(1); - if (strLen < 2) return "00"; - return s.readString(strLen); + if (strLen < 1) return null; + return s.getBytes(strLen); } diff --git a/src/core/operations/FromBech32.mjs b/src/core/operations/FromBech32.mjs new file mode 100644 index 00000000..8a01d4db --- /dev/null +++ b/src/core/operations/FromBech32.mjs @@ -0,0 +1,149 @@ +/** + * @author Medjedtxm + * @copyright Crown Copyright 2025 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import { decode } from "../lib/Bech32.mjs"; +import { toHex } from "../lib/Hex.mjs"; + +/** + * From Bech32 operation + */ +class FromBech32 extends Operation { + + /** + * FromBech32 constructor + */ + constructor() { + super(); + + this.name = "From Bech32"; + this.module = "Default"; + this.description = "Bech32 is an encoding scheme primarily used for Bitcoin SegWit addresses (BIP-0173). It uses a 32-character alphabet that excludes easily confused characters (1, b, i, o) and includes a checksum for error detection.

Bech32m (BIP-0350) is an updated version used for Bitcoin Taproot addresses.

Auto-detect will attempt Bech32 first, then Bech32m if the checksum fails.

Output format options allow you to see the Human-Readable Part (HRP) along with the decoded data."; + this.infoURL = "https://wikipedia.org/wiki/Bech32"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Encoding", + "type": "option", + "value": ["Auto-detect", "Bech32", "Bech32m"] + }, + { + "name": "Output Format", + "type": "option", + "value": ["Raw", "Hex", "Bitcoin scriptPubKey", "HRP: Hex", "JSON"] + } + ]; + this.checks = [ + { + // Bitcoin mainnet SegWit/Taproot addresses + pattern: "^bc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,87}$", + flags: "i", + args: ["Auto-detect", "Hex"] + }, + { + // Bitcoin testnet addresses + pattern: "^tb1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,87}$", + flags: "i", + args: ["Auto-detect", "Hex"] + }, + { + // AGE public keys + pattern: "^age1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,87}$", + flags: "i", + args: ["Auto-detect", "HRP: Hex"] + }, + { + // AGE secret keys + pattern: "^AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{6,87}$", + flags: "", + args: ["Auto-detect", "HRP: Hex"] + }, + { + // Litecoin mainnet addresses + pattern: "^ltc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,87}$", + flags: "i", + args: ["Auto-detect", "Hex"] + }, + { + // Generic bech32 pattern + pattern: "^[a-z]{1,83}1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,}$", + flags: "i", + args: ["Auto-detect", "Hex"] + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const encoding = args[0]; + const outputFormat = args[1]; + + input = input.trim(); + + if (input.length === 0) { + return ""; + } + + const decoded = decode(input, encoding); + + // Format output based on selected option + switch (outputFormat) { + case "Raw": + return decoded.data.map(b => String.fromCharCode(b)).join(""); + + case "Hex": + return toHex(decoded.data, ""); + + case "Bitcoin scriptPubKey": { + // Convert to Bitcoin scriptPubKey format as shown in BIP-0173/BIP-0350 + // Format: [OP_version][length][witness_program] + // OP_0 = 0x00, OP_1-OP_16 = 0x51-0x60 + if (decoded.witnessVersion === null || decoded.data.length < 2) { + // Not a SegWit address, fall back to hex + return toHex(decoded.data, ""); + } + const witnessVersion = decoded.data[0]; + const witnessProgram = decoded.data.slice(1); + + // Convert witness version to OP code + let opCode; + if (witnessVersion === 0) { + opCode = 0x00; // OP_0 + } else if (witnessVersion >= 1 && witnessVersion <= 16) { + opCode = 0x50 + witnessVersion; // OP_1 = 0x51, ..., OP_16 = 0x60 + } else { + // Invalid witness version, fall back to hex + return toHex(decoded.data, ""); + } + + // Build scriptPubKey: [OP_version][length][program] + const scriptPubKey = [opCode, witnessProgram.length, ...witnessProgram]; + return toHex(scriptPubKey, ""); + } + + case "HRP: Hex": + return `${decoded.hrp}: ${toHex(decoded.data, "")}`; + + case "JSON": + return JSON.stringify({ + hrp: decoded.hrp, + encoding: decoded.encoding, + data: toHex(decoded.data, "") + }, null, 2); + + default: + return toHex(decoded.data, ""); + } + } + +} + +export default FromBech32; diff --git a/src/core/operations/ToBase85.mjs b/src/core/operations/ToBase85.mjs index 839ef1e4..e9b0a485 100644 --- a/src/core/operations/ToBase85.mjs +++ b/src/core/operations/ToBase85.mjs @@ -33,7 +33,7 @@ class ToBase85 extends Operation { value: ALPHABET_OPTIONS }, { - name: "Include delimeter", + name: "Include delimiter", type: "boolean", value: false } diff --git a/src/core/operations/ToBech32.mjs b/src/core/operations/ToBech32.mjs new file mode 100644 index 00000000..a7c97355 --- /dev/null +++ b/src/core/operations/ToBech32.mjs @@ -0,0 +1,92 @@ +/** + * @author Medjedtxm + * @copyright Crown Copyright 2025 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import { encode } from "../lib/Bech32.mjs"; +import { fromHex } from "../lib/Hex.mjs"; + +/** + * To Bech32 operation + */ +class ToBech32 extends Operation { + + /** + * ToBech32 constructor + */ + constructor() { + super(); + + this.name = "To Bech32"; + this.module = "Default"; + this.description = "Bech32 is an encoding scheme primarily used for Bitcoin SegWit addresses (BIP-0173). It uses a 32-character alphabet that excludes easily confused characters (1, b, i, o) and includes a checksum for error detection.

Bech32m (BIP-0350) is an updated version that fixes a weakness in the original Bech32 checksum and is used for Bitcoin Taproot addresses.

The Human-Readable Part (HRP) identifies the network or purpose (e.g., 'bc' for Bitcoin mainnet, 'tb' for testnet, 'age' for AGE encryption keys).

Maximum output length is 90 characters as per specification."; + this.infoURL = "https://wikipedia.org/wiki/Bech32"; + this.inputType = "ArrayBuffer"; + this.outputType = "string"; + this.args = [ + { + "name": "Human-Readable Part (HRP)", + "type": "string", + "value": "bc" + }, + { + "name": "Encoding", + "type": "option", + "value": ["Bech32", "Bech32m"] + }, + { + "name": "Input Format", + "type": "option", + "value": ["Raw bytes", "Hex"] + }, + { + "name": "Mode", + "type": "option", + "value": ["Generic", "Bitcoin SegWit"] + }, + { + "name": "Witness Version", + "type": "number", + "value": 0, + "hint": "SegWit witness version (0-16). Only used in Bitcoin SegWit mode." + } + ]; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const hrp = args[0]; + const encoding = args[1]; + const inputFormat = args[2]; + const mode = args[3]; + const witnessVersion = args[4]; + + let inputArray; + if (inputFormat === "Hex") { + // Convert hex string to bytes + const hexStr = new TextDecoder().decode(new Uint8Array(input)).replace(/\s/g, ""); + inputArray = fromHex(hexStr); + } else { + inputArray = new Uint8Array(input); + } + + if (mode === "Bitcoin SegWit") { + // Prepend witness version to the input data + const withVersion = new Uint8Array(inputArray.length + 1); + withVersion[0] = witnessVersion; + withVersion.set(inputArray, 1); + return encode(hrp, withVersion, encoding, true); + } + + return encode(hrp, inputArray, encoding, false); + } + +} + +export default ToBech32; diff --git a/tests/node/tests/Utils.mjs b/tests/node/tests/Utils.mjs index 8dbf37ae..5ee7c936 100644 --- a/tests/node/tests/Utils.mjs +++ b/tests/node/tests/Utils.mjs @@ -20,4 +20,10 @@ TestRegister.addApiTests([ assert.equal(Utils.parseEscapedChars("\\\\\\'"), "\\'"); }), + it("Utils: should replace delete character", () => { + assert.equal( + Utils.printable("\x7e\x7f\x80\xa7", false, true), + "\x7e...", + ); + }), ]); diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index f147e9e7..6d5b266f 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -26,6 +26,7 @@ import "./tests/Base64.mjs"; import "./tests/Base85.mjs"; import "./tests/Base92.mjs"; import "./tests/BCD.mjs"; +import "./tests/Bech32.mjs"; import "./tests/BitwiseOp.mjs"; import "./tests/BLAKE2b.mjs"; import "./tests/BLAKE2s.mjs"; diff --git a/tests/operations/tests/Bech32.mjs b/tests/operations/tests/Bech32.mjs new file mode 100644 index 00000000..85325d47 --- /dev/null +++ b/tests/operations/tests/Bech32.mjs @@ -0,0 +1,702 @@ +/** + * Bech32 tests. + * + * Test vectors from official BIP specifications: + * BIP-0173: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki + * BIP-0350: https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki + * + * AGE key test vectors from: + * https://asecuritysite.com/age/go_age5 + * + * @author Medjedtxm + * @copyright Crown Copyright 2025 + * @license Apache-2.0 + */ + +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + // ============= To Bech32 Tests ============= + { + name: "To Bech32: empty input", + input: "", + expectedOutput: "bc1gmk9yu", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32: single byte", + input: "A", + expectedOutput: "bc1gyufle22", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32: Hello", + input: "Hello", + expectedOutput: "bc1fpjkcmr0gzsgcg", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32: custom HRP", + input: "test", + expectedOutput: "custom1w3jhxaq593qur", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["custom", "Bech32", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32: testnet HRP", + input: "data", + expectedOutput: "tb1v3shgcg3x07jr", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["tb", "Bech32", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32m: empty input", + input: "", + expectedOutput: "bc1a8xfp7", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32m", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32m: single byte", + input: "A", + expectedOutput: "bc1gyf4040g", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32m", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32m: Hello", + input: "Hello", + expectedOutput: "bc1fpjkcmr0a7qya2", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32m", "Raw bytes", "Generic", 0] + } + ], + }, + { + name: "To Bech32: empty HRP error", + input: "test", + expectedOutput: "Human-Readable Part (HRP) cannot be empty.", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["", "Bech32", "Raw bytes", "Generic", 0] + } + ], + }, + + // ============= From Bech32 Tests (Raw output) ============= + { + name: "From Bech32: decode single byte (Raw)", + input: "bc1gyufle22", + expectedOutput: "A", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Raw"] + } + ], + }, + { + name: "From Bech32: decode Hello (Raw)", + input: "bc1fpjkcmr0gzsgcg", + expectedOutput: "Hello", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Raw"] + } + ], + }, + { + name: "From Bech32: auto-detect Bech32 (Raw)", + input: "bc1fpjkcmr0gzsgcg", + expectedOutput: "Hello", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Raw"] + } + ], + }, + { + name: "From Bech32m: decode Hello (Raw)", + input: "bc1fpjkcmr0a7qya2", + expectedOutput: "Hello", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "Raw"] + } + ], + }, + { + name: "From Bech32: auto-detect Bech32m (Raw)", + input: "bc1fpjkcmr0a7qya2", + expectedOutput: "Hello", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Raw"] + } + ], + }, + { + name: "From Bech32: uppercase input (Raw)", + input: "BC1FPJKCMR0GZSGCG", + expectedOutput: "Hello", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Raw"] + } + ], + }, + { + name: "From Bech32: custom HRP (Raw)", + input: "custom1w3jhxaq593qur", + expectedOutput: "test", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Raw"] + } + ], + }, + { + name: "From Bech32: empty input", + input: "", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Hex"] + } + ], + }, + { + name: "From Bech32: empty data part (Hex)", + input: "bc1gmk9yu", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + + // ============= From Bech32 HRP Output Tests ============= + { + name: "From Bech32: HRP: Hex output format", + input: "bc1fpjkcmr0gzsgcg", + expectedOutput: "bc: 48656c6c6f", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: JSON output format", + input: "bc1fpjkcmr0gzsgcg", + expectedOutput: "{\n \"hrp\": \"bc\",\n \"encoding\": \"Bech32\",\n \"data\": \"48656c6c6f\"\n}", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "JSON"] + } + ], + }, + { + name: "From Bech32: Hex output format", + input: "bc1fpjkcmr0gzsgcg", + expectedOutput: "48656c6c6f", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + + // ============= AGE Key Test Vectors ============= + // From: https://asecuritysite.com/age/go_age5 + { + name: "From Bech32: AGE public key 1 (HRP: Hex)", + input: "age1kk86t4lr4s9uwvnqjzp2e35rflvcpnjt33q99547ct23xzk0ssss3ma49j", + expectedOutput: "age: b58fa5d7e3ac0bc732609082acc6834fd980ce4b8c4052d2bec2d5130acf8421", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: AGE private key 1 (HRP: Hex)", + input: "AGE-SECRET-KEY-1Z5N23X54Y4E9NLMPNH6EZDQQX9V883TMKJ3ZJF5QXXMKNZ2RPFXQUQF74G", + expectedOutput: "age-secret-key-: 1526a89a95257259ff619df5913400315873c57bb4a229268031b76989430a4c", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: AGE public key 2 (HRP: Hex)", + input: "age1nwt7gkq7udvalagqn7l8a4jgju7wtenkg925pvuqvn7cfcry6u2qkae4ad", + expectedOutput: "age: 9b97e4581ee359dff5009fbe7ed648973ce5e676415540b38064fd84e064d714", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: AGE private key 2 (HRP: Hex)", + input: "AGE-SECRET-KEY-137M0YVE3CL6M8C4ET9L2KU67FPQHJZTW547QD5CK0R5A5T09ZGJSQGR9LX", + expectedOutput: "age-secret-key-: 8fb6f23331c7f5b3e2b9597eab735e484179096ea57c06d31678e9da2de51225", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: AGE public key 1 (JSON)", + input: "age1kk86t4lr4s9uwvnqjzp2e35rflvcpnjt33q99547ct23xzk0ssss3ma49j", + expectedOutput: "{\n \"hrp\": \"age\",\n \"encoding\": \"Bech32\",\n \"data\": \"b58fa5d7e3ac0bc732609082acc6834fd980ce4b8c4052d2bec2d5130acf8421\"\n}", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "JSON"] + } + ], + }, + + // ============= Error Cases ============= + { + name: "From Bech32: mixed case error", + input: "bc1FpjKcmr0gzsgcg", + expectedOutput: "Invalid Bech32 string: mixed case is not allowed. Use all uppercase or all lowercase.", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Hex"] + } + ], + }, + { + name: "From Bech32: no separator error", + input: "noseparator", + expectedOutput: "Invalid Bech32 string: no separator '1' found.", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Hex"] + } + ], + }, + { + name: "From Bech32: empty HRP error", + input: "1qqqqqqqqqqqqqqqq", + expectedOutput: "Invalid Bech32 string: Human-Readable Part (HRP) cannot be empty.", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Hex"] + } + ], + }, + { + name: "From Bech32: invalid checksum", + input: "bc1fpjkcmr0gzsgcx", + expectedOutput: "Invalid Bech32/Bech32m string: checksum verification failed.", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Hex"] + } + ], + }, + { + name: "From Bech32: data too short", + input: "bc1abc", + expectedOutput: "Invalid Bech32 string: data part is too short (minimum 6 characters for checksum).", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Hex"] + } + ], + }, + { + name: "From Bech32: wrong encoding specified", + input: "bc1fpjkcmr0gzsgcg", + expectedOutput: "Invalid Bech32m checksum.", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "Hex"] + } + ], + }, + + // ============= BIP-0173 Test Vectors (Bech32) ============= + { + name: "From Bech32: BIP-0173 A12UEL5L (empty data)", + input: "A12UEL5L", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + { + name: "From Bech32: BIP-0173 a12uel5l lowercase", + input: "a12uel5l", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + { + name: "From Bech32: BIP-0173 long HRP with bio", + input: "an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + { + name: "From Bech32: BIP-0173 abcdef with data", + input: "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", + expectedOutput: "abcdef: 00443214c74254b635cf84653a56d7c675be77df", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: BIP-0173 split HRP", + input: "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", + expectedOutput: "split: c5f38b70305f519bf66d85fb6cf03058f3dde463ecd7918f2dc743918f2d", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32: BIP-0173 question mark HRP", + input: "?1ezyfcl", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + + // ============= BIP-0350 Test Vectors (Bech32m) ============= + { + name: "From Bech32m: BIP-0350 A1LQFN3A (empty data)", + input: "A1LQFN3A", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "Hex"] + } + ], + }, + { + name: "From Bech32m: BIP-0350 a1lqfn3a lowercase", + input: "a1lqfn3a", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "Hex"] + } + ], + }, + { + name: "From Bech32m: BIP-0350 long HRP", + input: "an83characterlonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11sg7hg6", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "Hex"] + } + ], + }, + { + name: "From Bech32m: BIP-0350 abcdef with data", + input: "abcdef1l7aum6echk45nj3s0wdvt2fg8x9yrzpqzd3ryx", + expectedOutput: "abcdef: ffbbcdeb38bdab49ca307b9ac5a928398a418820", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32m: BIP-0350 split HRP", + input: "split1checkupstagehandshakeupstreamerranterredcaperredlc445v", + expectedOutput: "split: c5f38b70305f519bf66d85fb6cf03058f3dde463ecd7918f2dc743918f2d", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "HRP: Hex"] + } + ], + }, + { + name: "From Bech32m: BIP-0350 question mark HRP", + input: "?1v759aa", + expectedOutput: "", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Bech32m", "Hex"] + } + ], + }, + + // ============= Bitcoin scriptPubKey Output Format Tests ============= + // Test vectors from BIP-0173 and BIP-0350 + { + name: "From Bech32: Bitcoin scriptPubKey v0 P2WPKH", + input: "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4", + expectedOutput: "0014751e76e8199196d454941c45d1b3a323f1433bd6", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Bitcoin scriptPubKey"] + } + ], + }, + { + name: "From Bech32: Bitcoin scriptPubKey v0 P2WSH", + input: "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", + expectedOutput: "00201863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Bitcoin scriptPubKey"] + } + ], + }, + { + name: "From Bech32: Bitcoin scriptPubKey v1 Taproot (Bech32m)", + input: "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", + expectedOutput: "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Bitcoin scriptPubKey"] + } + ], + }, + { + name: "From Bech32: Bitcoin scriptPubKey v16", + input: "BC1SW50QGDZ25J", + expectedOutput: "6002751e", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Bitcoin scriptPubKey"] + } + ], + }, + { + name: "From Bech32: Bitcoin scriptPubKey v2", + input: "bc1zw508d6qejxtdg4y5r3zarvaryvaxxpcs", + expectedOutput: "5210751e76e8199196d454941c45d1b3a323", + recipeConfig: [ + { + "op": "From Bech32", + "args": ["Auto-detect", "Bitcoin scriptPubKey"] + } + ], + }, + + // ============= Bitcoin SegWit Encoding Tests ============= + { + name: "To Bech32: Bitcoin SegWit v0 P2WPKH", + input: "751e76e8199196d454941c45d1b3a323f1433bd6", + expectedOutput: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32", "Hex", "Bitcoin SegWit", 0] + } + ], + }, + { + name: "To Bech32: Bitcoin SegWit v0 P2WSH testnet", + input: "1863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262", + expectedOutput: "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["tb", "Bech32", "Hex", "Bitcoin SegWit", 0] + } + ], + }, + { + name: "To Bech32m: Bitcoin Taproot v1", + input: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + expectedOutput: "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32m", "Hex", "Bitcoin SegWit", 1] + } + ], + }, + { + name: "To Bech32m: Bitcoin SegWit v16", + input: "751e", + expectedOutput: "bc1sw50qgdz25j", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["bc", "Bech32m", "Hex", "Bitcoin SegWit", 16] + } + ], + }, + + // ============= Round-trip Tests ============= + { + name: "Bech32: encode then decode round-trip", + input: "The quick brown fox jumps over the lazy dog", + expectedOutput: "The quick brown fox jumps over the lazy dog", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["test", "Bech32", "Raw bytes", "Generic", 0] + }, + { + "op": "From Bech32", + "args": ["Bech32", "Raw"] + } + ], + }, + { + name: "Bech32m: encode then decode round-trip", + input: "The quick brown fox jumps over the lazy dog", + expectedOutput: "The quick brown fox jumps over the lazy dog", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["test", "Bech32m", "Raw bytes", "Generic", 0] + }, + { + "op": "From Bech32", + "args": ["Bech32m", "Raw"] + } + ], + }, + { + name: "Bech32: binary data round-trip", + input: "0001020304050607", + expectedOutput: "0001020304050607", + recipeConfig: [ + { + "op": "From Hex", + "args": ["Auto"] + }, + { + "op": "To Bech32", + "args": ["bc", "Bech32", "Raw bytes", "Generic", 0] + }, + { + "op": "From Bech32", + "args": ["Bech32", "Hex"] + } + ], + }, + { + name: "Bech32: auto-detect round-trip", + input: "CyberChef Bech32 Test", + expectedOutput: "CyberChef Bech32 Test", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["cyberchef", "Bech32", "Raw bytes", "Generic", 0] + }, + { + "op": "From Bech32", + "args": ["Auto-detect", "Raw"] + } + ], + }, + { + name: "Bech32m: auto-detect round-trip", + input: "CyberChef Bech32m Test", + expectedOutput: "CyberChef Bech32m Test", + recipeConfig: [ + { + "op": "To Bech32", + "args": ["cyberchef", "Bech32m", "Raw bytes", "Generic", 0] + }, + { + "op": "From Bech32", + "args": ["Auto-detect", "Raw"] + } + ], + }, +]); diff --git a/tests/operations/tests/Code.mjs b/tests/operations/tests/Code.mjs index 7d14bc61..0a25c0e8 100644 --- a/tests/operations/tests/Code.mjs +++ b/tests/operations/tests/Code.mjs @@ -336,8 +336,7 @@ TestRegister.addTests([ ] } ], - expectedMatch: /^Invalid JPath expression: jsonPath: Cannot read properties of {2}\(reading 'constructor'\): / - }, + expectedMatch: /^Invalid JPath expression: jsonPath: Cannot read properties of {2}\(reading 'constructor'\): / }, { name: "CSS selector", input: '
\n

hello

\n

world

\n

again

\n
', diff --git a/tests/operations/tests/JA4.mjs b/tests/operations/tests/JA4.mjs index 0fb4624e..699dca40 100644 --- a/tests/operations/tests/JA4.mjs +++ b/tests/operations/tests/JA4.mjs @@ -30,6 +30,28 @@ TestRegister.addTests([ } ], }, + { + name: "JA4 Fingerprint: TLS 1.3 with whitespace-only ALPN", + input: "1603010200010001fc0303ed338a18e711d670cdc472ff570a5b59f1ace12e5365918bf68bf845019147b6207e4437bfb062d98a4aeb753be8f09022a9dc9413d7694dad4db57fcdcf076e820024130213031301c02cc030c02bc02fcca9cca8c024c028c023c027009f009e006b006700ff0100018f0000001800160000136465762e636f6e74656e74677261622e6e6574000b000403000102000a00160014001d0017001e00190018010001010102010301040023000000100004000201200016000000170000000d002a0028040305030603080708080809080a080b080408050806040105010601030303010302040205020602002b00050403040303002d00020101003300260024001d00207af053336d5e2c1675aa4c6ce78de5e5fdbd296538113f051ea17ccb64289f22001500d2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + expectedOutput: "t13d181220_85036bcba153_d41ae481755e", + recipeConfig: [ + { + "op": "JA4 Fingerprint", + "args": ["Hex", "JA4"] + } + ], + }, + { + name: "JA4 Fingerprint: TLS 1.3 with ALPN containing a whitespace", + input: "1603010200010001fc0303273682a603be3f64dd025df4ad0f4d2d13043c3a233405a68bb29b865808749a20f4dfc40242b2fce38fae26c516ef9bef20a1b9349eba3c003780168d72471f5c0024130213031301c02cc030c02bc02fcca9cca8c024c028c023c027009f009e006b006700ff0100018f0000001800160000136465762e636f6e74656e74677261622e6e6574000b000403000102000a00160014001d0017001e0019001801000101010201030104002300000010000500030261200016000000170000000d002a0028040305030603080708080809080a080b080408050806040105010601030303010302040205020602002b00050403040303002d00020101003300260024001d0020f4dd1567bd858d3a9f1d88db1fee6a10ab0ea1aa6afe96ffb6a7c4d79dea4075001500d10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + expectedOutput: "t13d181260_85036bcba153_d41ae481755e", + recipeConfig: [ + { + "op": "JA4 Fingerprint", + "args": ["Hex", "JA4"] + } + ], + }, { name: "JA4 Fingerprint: TLS 1.2", input: "1603010200010001fc0303ecb2691addb2bf6c599c7aaae23de5f42561cc04eb41029acc6fc050a16ac1d22046f8617b580ac9358e2aa44e306d52466bcc989c87c8ca64309f5faf50ba7b4d0022130113031302c02bc02fcca9cca8c02cc030c00ac009c013c014009c009d002f00350100019100000021001f00001c636f6e74696c652e73657276696365732e6d6f7a696c6c612e636f6d00170000ff01000100000a000e000c001d00170018001901000101000b00020100002300000010000e000c02683208687474702f312e310005000501000000000022000a000804030503060302030033006b0069001d00208909858fbeb6ed2f1248ba5b9e2978bead0e840110192c61daed0096798b184400170041044d183d91f5eed35791fa982464e3b0214aaa5f5d1b78616d9b9fbebc22d11f535b2f94c686143136aa795e6e5a875d6c08064ad5b76d44caad766e2483012748002b00050403040303000d0018001604030503060308040805080604010501060102030201002d00020101001c000240010015007a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",