From dfcb7349b73f86a82c46993cc6b842303977c76e Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 29 Jul 2026 14:21:50 +0200 Subject: [PATCH 1/4] js: strict receiver validation per BBQr BIP draft --- js/src/join.ts | 35 ++++++++++++++++++++++++++++++++++- js/src/utils.ts | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/js/src/join.ts b/js/src/join.ts index 351c496..aa7c653 100644 --- a/js/src/join.ts +++ b/js/src/join.ts @@ -4,10 +4,14 @@ * QR code decoding/joining. */ -import { ENCODINGS } from './consts'; +import { ENCODINGS, HEADER_LEN } from './consts'; import { Encoding, JoinResult } from './types'; import { decodeData } from './utils'; +// strict header grammar: B$ magic, known encoding, one uppercase letter of +// file type, then uppercase base-36 digits for part count and index +const HEADER_RE = /^B\$[H2Z][A-Z][0-9A-Z]{2}[0-9A-Z]{2}$/; + /** * Decodes and joins QR code parts back to binary data. * @@ -15,6 +19,16 @@ import { decodeData } from './utils'; * @returns Object containing the file type, encoding, and raw binary data. */ export function joinQRs(parts: string[]): JoinResult { + for (const p of parts) { + if (!HEADER_RE.test(p.slice(0, HEADER_LEN))) { + throw new Error(`invalid header: ${p.slice(0, HEADER_LEN)}`); + } + + if (p.length === HEADER_LEN) { + throw new Error('empty body'); + } + } + const headers = new Set(parts.map((p) => p.slice(0, 6))); if (headers.size !== 1) { @@ -45,6 +59,7 @@ export function joinQRs(parts: string[]): JoinResult { } const data = new Map(); + let bodyLen: number | null = null; for (const p of parts) { const idx = parseInt(p.slice(6, 8), 36); @@ -58,6 +73,15 @@ export function joinQRs(parts: string[]): JoinResult { } data.set(idx, p.slice(8)); + + if (idx !== numParts - 1) { + // all non-final bodies must share one length + bodyLen = bodyLen ?? p.length - HEADER_LEN; + + if (p.length - HEADER_LEN !== bodyLen) { + throw new Error('non-final parts must have equal length'); + } + } } const orderedParts = []; @@ -72,8 +96,17 @@ export function joinQRs(parts: string[]): JoinResult { orderedParts.push(p); } + if (numParts > 1 && orderedParts[numParts - 1].length > bodyLen!) { + // final body must be no longer than the others + throw new Error('final part too long'); + } + const raw = decodeData(orderedParts, encoding); + if (!raw.length) { + throw new Error('empty transfer'); + } + return { fileType, encoding, raw }; } diff --git a/js/src/utils.ts b/js/src/utils.ts index a416584..6de003a 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -9,6 +9,8 @@ import pako from 'pako'; import { QR_DATA_CAPACITY } from './consts'; import type { Encoding, SplitOptions, Version } from './types'; +const HEX_RE = /^[0-9A-F]*$/; + export function hexToBytes(hex: string) { // convert a hex string to a Uint8Array @@ -198,11 +200,23 @@ export function decodeData(parts: string[], encoding: Encoding) { // decode the parts back into a Uint8Array if (encoding === 'H') { - return joinByteParts(parts.map((p) => hexToBytes(p))); + return joinByteParts( + parts.map((p) => { + if (p.length % 2 || !HEX_RE.test(p)) { + throw new Error('non-canonical hex body'); + } + + return hexToBytes(p); + }) + ); } const bytes = joinByteParts( - parts.map((p) => { + parts.map((p, i) => { + if (i < parts.length - 1 && p.length % 8) { + throw new Error('non-final Base32 body length must be a multiple of 8'); + } + const padding = (8 - (p.length % 8)) % 8; return base32.decode(p + '='.repeat(padding)); @@ -210,7 +224,24 @@ export function decodeData(parts: string[], encoding: Encoding) { ); if (encoding === 'Z') { - return pako.inflate(bytes, { windowBits: -10 }); + const inflator = new pako.Inflate({ windowBits: -10 }); + inflator.push(bytes, true); + + if (inflator.err) { + throw new Error(`invalid DEFLATE stream: ${inflator.msg}`); + } + + const { ended, strm } = inflator as any; + + if (!ended) { + throw new Error('incomplete DEFLATE stream'); + } + + if (strm.avail_in) { + throw new Error('trailing data after DEFLATE stream'); + } + + return inflator.result as Uint8Array; } return bytes; From f5ebcb5840497f4e0be42a6af7be62cc87d31ec6 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 29 Jul 2026 14:54:11 +0200 Subject: [PATCH 2/4] js: enforce 1k DEFLATE window and size cap while inflating; add vector tests --- js/src/consts.ts | 3 + js/src/join.ts | 7 +- js/src/utils.ts | 38 +++++- js/tests/vectors.test.ts | 47 ++++++++ test_data/bip-test-vectors.json | 152 ++++++++++++++++++++++++ test_data/deflate-overwide-distance.txt | 1 + 6 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 js/tests/vectors.test.ts create mode 100644 test_data/bip-test-vectors.json create mode 100644 test_data/deflate-overwide-distance.txt diff --git a/js/src/consts.ts b/js/src/consts.ts index 0c8f292..85fa32b 100644 --- a/js/src/consts.ts +++ b/js/src/consts.ts @@ -9,6 +9,9 @@ import { Version } from './types'; // Fixed-length header export const HEADER_LEN = 8; +// Default cap on decoded/decompressed transfer size (overridable per call) +export const DEFAULT_MAX_SIZE = 16 * 1024 * 1024; + export const FILETYPE_NAMES = { P: 'PSBT', T: 'Transaction', diff --git a/js/src/join.ts b/js/src/join.ts index aa7c653..4c32850 100644 --- a/js/src/join.ts +++ b/js/src/join.ts @@ -4,7 +4,7 @@ * QR code decoding/joining. */ -import { ENCODINGS, HEADER_LEN } from './consts'; +import { DEFAULT_MAX_SIZE, ENCODINGS, HEADER_LEN } from './consts'; import { Encoding, JoinResult } from './types'; import { decodeData } from './utils'; @@ -16,9 +16,10 @@ const HEADER_RE = /^B\$[H2Z][A-Z][0-9A-Z]{2}[0-9A-Z]{2}$/; * Decodes and joins QR code parts back to binary data. * * @param parts Array of QR code parts + * @param maxSize Cap on decoded/decompressed transfer size in bytes. * @returns Object containing the file type, encoding, and raw binary data. */ -export function joinQRs(parts: string[]): JoinResult { +export function joinQRs(parts: string[], maxSize = DEFAULT_MAX_SIZE): JoinResult { for (const p of parts) { if (!HEADER_RE.test(p.slice(0, HEADER_LEN))) { throw new Error(`invalid header: ${p.slice(0, HEADER_LEN)}`); @@ -101,7 +102,7 @@ export function joinQRs(parts: string[]): JoinResult { throw new Error('final part too long'); } - const raw = decodeData(orderedParts, encoding); + const raw = decodeData(orderedParts, encoding, maxSize); if (!raw.length) { throw new Error('empty transfer'); diff --git a/js/src/utils.ts b/js/src/utils.ts index 6de003a..83f3cbb 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -6,7 +6,7 @@ import { base32 } from '@scure/base'; import pako from 'pako'; -import { QR_DATA_CAPACITY } from './consts'; +import { DEFAULT_MAX_SIZE, QR_DATA_CAPACITY } from './consts'; import type { Encoding, SplitOptions, Version } from './types'; const HEX_RE = /^[0-9A-F]*$/; @@ -196,11 +196,11 @@ export function encodeData(raw: Uint8Array, encoding?: Encoding) { }; } -export function decodeData(parts: string[], encoding: Encoding) { +export function decodeData(parts: string[], encoding: Encoding, maxSize = DEFAULT_MAX_SIZE) { // decode the parts back into a Uint8Array if (encoding === 'H') { - return joinByteParts( + const raw = joinByteParts( parts.map((p) => { if (p.length % 2 || !HEX_RE.test(p)) { throw new Error('non-canonical hex body'); @@ -209,6 +209,12 @@ export function decodeData(parts: string[], encoding: Encoding) { return hexToBytes(p); }) ); + + if (raw.length > maxSize) { + throw new Error('decoded data too large'); + } + + return raw; } const bytes = joinByteParts( @@ -223,8 +229,30 @@ export function decodeData(parts: string[], encoding: Encoding) { }) ); + if (bytes.length > maxSize) { + throw new Error('decoded data too large'); + } + if (encoding === 'Z') { - const inflator = new pako.Inflate({ windowBits: -10 }); + // small output chunks keep zlib's back-reference distance check close to + // the 1k window implied by wbits=10, and let the size cap apply while + // inflating instead of after the full output has been buffered + const inflator = new pako.Inflate({ windowBits: -10, chunkSize: 1024 }); + + const chunks: Uint8Array[] = []; + let total = 0; + + inflator.onData = (data) => { + const chunk = data as Uint8Array; + total += chunk.length; + + if (total > maxSize) { + throw new Error('decompressed data too large'); + } + + chunks.push(chunk); + }; + inflator.push(bytes, true); if (inflator.err) { @@ -241,7 +269,7 @@ export function decodeData(parts: string[], encoding: Encoding) { throw new Error('trailing data after DEFLATE stream'); } - return inflator.result as Uint8Array; + return joinByteParts(chunks); } return bytes; diff --git a/js/tests/vectors.test.ts b/js/tests/vectors.test.ts new file mode 100644 index 0000000..bc431bf --- /dev/null +++ b/js/tests/vectors.test.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { describe, expect, test } from 'vitest'; +import { joinQRs } from '../src/join'; + +const doc = JSON.parse( + readFileSync(new URL('../../test_data/bip-test-vectors.json', import.meta.url), 'utf-8') +); + +describe('BBQr BIP draft vectors', () => { + for (const v of doc.vectors) { + test(`decode ${v.name}`, () => { + for (const frames of [v.frames, [...v.frames].reverse()]) { + const { fileType, raw } = joinQRs(frames); + + expect(fileType).toBe(v.file_type); + expect(raw.length).toBe(v.input_length); + expect(createHash('sha256').update(raw).digest('hex')).toBe(v.input_sha256); + } + }); + } + + for (const c of doc.invalid_cases) { + test(`reject ${c.name}`, () => { + expect(() => joinQRs(c.frames)).toThrow(); + }); + } +}); + +describe('DEFLATE resource limits', () => { + test('reject back-reference distance beyond the 1k window', () => { + const frame = readFileSync( + new URL('../../test_data/deflate-overwide-distance.txt', import.meta.url), + 'utf-8' + ).trim(); + + expect(() => joinQRs([frame])).toThrow(/distance/); + }); + + test('cap decompressed size while inflating', () => { + const v = doc.vectors.find((v: { name: string }) => v.name === 'deflate-psbt'); + + // compressed input fits the cap; decompressed output must not + expect(() => joinQRs(v.frames, v.input_length - 1)).toThrow(/too large/); + expect(joinQRs(v.frames).raw.length).toBe(v.input_length); + }); +}); diff --git a/test_data/bip-test-vectors.json b/test_data/bip-test-vectors.json new file mode 100644 index 0000000..c2213d8 --- /dev/null +++ b/test_data/bip-test-vectors.json @@ -0,0 +1,152 @@ +{ + "specification": "BBQr transport profile", + "license": "CC0-1.0", + "notes": [ + "Frames were generated independently by the Python and JavaScript reference implementations and matched exactly.", + "All vectors decode in reverse frame order.", + "Raw DEFLATE output is not canonical across compressor implementations; decoders must accept any valid stream constrained to a 1024-byte window." + ], + "generated_from": { + "coinkite_bbqr_repository": "https://github.com/coinkite/BBQr", + "coinkite_bbqr_commit": "d33d7555558a5e5320914a773be62ecf471de482", + "python_version": "1.0.1", + "javascript_version": "1.2.0", + "bbqr_rust_repository": "https://github.com/SatoshiPortal/bbqr-rust", + "bbqr_rust_commit": "5569be645787f64372813aeb479d9bca72301e19", + "bbqr_rust_version": "0.5.0" + }, + "invalid_cases": [ + { + "name": "zero-total", + "reason": "The total frame count is zero.", + "frames": ["B$HP0000AA"] + }, + { + "name": "index-not-less-than-total", + "reason": "Index 01 is not valid when the total is 01.", + "frames": ["B$HP0101AA"] + }, + { + "name": "maximum-index-overflow", + "reason": "For total ZZ, the last valid index is ZY.", + "frames": ["B$HTZZZZAA"] + }, + { + "name": "unknown-encoding", + "reason": "Q is not a defined encoding.", + "frames": ["B$QP0100AA"] + }, + { + "name": "empty-body", + "reason": "Every frame body is non-empty.", + "frames": ["B$HT0100"] + }, + { + "name": "odd-hex-body", + "reason": "Every H body has even length.", + "frames": ["B$HP0100A"] + }, + { + "name": "lowercase-hex-body", + "reason": "H uses uppercase hexadecimal.", + "frames": ["B$HT0100aa"] + }, + { + "name": "invalid-base32-terminal-length", + "reason": "An unpadded Base32 terminal body cannot have residue one.", + "frames": ["B$2P0100A"] + }, + { + "name": "noncanonical-base32-pad-bits", + "reason": "AB decodes with non-zero pad bits; the canonical encoding is AA.", + "frames": ["B$2P0100AB"] + }, + { + "name": "missing-frame", + "reason": "A two-frame transfer is incomplete without index 01.", + "frames": ["B$HT0200AA"] + }, + { + "name": "mixed-transfer-header", + "reason": "The type code changes within one collected transfer.", + "frames": ["B$HT0200AA", "B$HP0201CC"] + }, + { + "name": "conflicting-duplicate", + "reason": "The same index is supplied with two different bodies.", + "frames": ["B$HT0200AA", "B$HT0200BB", "B$HT0201CC"] + }, + { + "name": "zlib-wrapper", + "reason": "Z requires raw DEFLATE, not a zlib wrapper.", + "frames": ["B$ZP0100PCOEWTCKAYAAETIBE4"] + }, + { + "name": "deflate-trailing-data", + "reason": "Data follows the raw DEFLATE end-of-stream marker.", + "frames": ["B$ZP0100JNGEUBQAAA"] + } + ], + "vectors": [ + { + "name": "hex-transaction", + "description": "Single-frame uppercase hexadecimal encoding of the genesis coinbase transaction.", + "file_type": "T", + "requested_encoding": "H", + "actual_encoding": "H", + "qr_version": 8, + "input_length": 134, + "input_sha256": "1e120c45180578688c4aa403b2e1e30320baede01c402078cce5d42ca1446ca6", + "input_hex": "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0704ffff001d0104ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec11600ae1390813a627c66fb8be7947be63c52da7589379515d4e0a604f8141781e62294721166bf621e73a82cbf2342c858eeac00000000", + "frames": [ + "B$HT010001000000010000000000000000000000000000000000000000000000000000000000000000FFFFFFFF0704FFFF001D0104FFFFFFFF0100F2052A0100000043410496B538E853519C726A2C91E61EC11600AE1390813A627C66FB8BE7947BE63C52DA7589379515D4E0A604F8141781E62294721166BF621E73A82CBF2342C858EEAC00000000" + ] + }, + { + "name": "base32-psbt", + "description": "Three-frame Base32 encoding of a valid PSBT fixture with byte-aligned non-final chunks.", + "file_type": "P", + "requested_encoding": "2", + "actual_encoding": "2", + "qr_version": 10, + "input_length": 675, + "input_sha256": "2929866d35ee77eeca2f7addc3d17d81fdf777ac3208374a76f8f06ce9abf222", + "input_hex": "70736274ff0100c902000000030d0b15eda761956158644571cb32675916ce29de46c52adb133f80902a7c7bd80000000000ffffffff4f72b07125366b7aa7f09f68a6f02ceb535b6ebb854e679ba3864280827fb2e90000000000ffffffff77f5752181606a93589caf3704005426b2fea128fc7db65cee422db781bcb9170000000000ffffffff028ccff008000000001976a914ec019301c44b9ebb0d9a2488e0f13e9652535f2c88ac8ccff008000000001976a914d873daeb3d98eda8dba55a04e97e0fe87e30a59888ac00000000000100550200000001adde000000000000efbe000000000000000000000000000000000000000000004900000000ffffffff0100e1f505000000001976a9140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b8188ac000000002206032be372801d8460dda52ae178aad774a54800ba56f949c97b8a2f51e299209d060c0f0569430000000000000000000100550200000001adde000000000000efbe000000000000000000000000000000000000000000004900000000ffffffff0100e1f505000000001976a914828b746c9cc6e2fbb89394f54ff40effd2a4b65188ac00000000220602d922903b8eb295bb232fc0304d88cf1755a60179156fa8f84970f0d4072d5fbb0c0f0569430000000001000000000100550200000001adde000000000000efbe000000000000000000000000000000000000000000004900000000ffffffff0100e1f505000000001976a9141188d9744c0fe8e8faec772d0c481bf81400f90988ac000000002206021fb423d42512e49d75a9d27415047a74378243cf7aee07c1b69ba7da0a03d4280c0f05694300000000020000000000220202f2d482365a1e8e9bbfcc830141048e9949f8efbae9b9db6bbfabd85498b5f647100f0569430c000000220000006003000000", + "frames": [ + "B$2P0300OBZWE5H7AEAMSAQAAAAAGDILCXW2OYMVMFMGIRLRZMZGOWIWZYU54RWFFLNRGP4ASAVHY66YAAAAAAAA7777772POKYHCJJWNN5KP4E7NCTPALHLKNNW5O4FJZTZXI4GIKAIE75S5EAAAAAAAD777777O72XKIMBMBVJGWE4V43QIACUE2ZP5IJI7R63MXHOIIW3PAN4XELQAAAAAAAP777774BIZT7QBAAAAAAADF3KSFHMAGJQDRCLT25Q3GRERDQPCPUWKJJV6LEIVSGM74AIAAAAAAAZO2URJWDT3LVT3GHNVDN2KWQE5F7A72D6GCSZRCFMAAAAAAAAAEAFKAQAAAAADLO6AAAAAAAAADX34AAAAAAAAAAAAAAAAAAA", + "B$2P0301AAAAAAAAAAAAAAAAJEAAAAAA777777YBADQ7KBIAAAAAAGLWVEKAWJJXU7LPHTDGRSPJ7IBQH75TZLLOTOAYRLAAAAAAAIQGAMV6G4UADWCGBXNFFLQXRKWXOSSUQAF2K34UTSL3RIXVDYUZECOQMDAPAVUUGAAAAAAAAAAAAAAACACVAIAAAAABVXPAAAAAAAAAB356AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAESAAAAAAP777774AQBYPVAUAAAAAADF3KSFECRN2GZHGG4L53RE4U6VH7IDX72KSLMUMIVQAAAAAAEIDAFWJCSA5Y5MUVXMRS7QBQJWEM6F2VUYAXSFLPVD4ES4HQ2QDS2X53BQHQK2KDAAAAAAAB", + "B$2P0302AAAAAAABABKQEAAAAAA23XQAAAAAAAAA567AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACJAAAAAAH777776AIA4H2QKAAAAAABS5VJCQIYRWLUJQH6R2H25R3S2DCIDP4BIAHZBGEKYAAAAAACEBQCD62CHVBFCLSJ25NJ2J2BKBD2OQ3YEQ6PPLXAPQNWTOT5UCQD2QUAYDYFNFBQAAAAAABAAAAAAAACEAQC6LKIENS2D2HJXP6MQMAUCBEOTFE7R3525G45W257VPMFJGFV6ZDRADYFNFBQYAAAAARAAAAAMABQAAAA" + ] + }, + { + "name": "deflate-psbt", + "description": "Two-frame raw-DEFLATE-plus-Base32 encoding of the same valid PSBT fixture.", + "file_type": "P", + "requested_encoding": "Z", + "actual_encoding": "Z", + "qr_version": 10, + "input_length": 675, + "input_sha256": "2929866d35ee77eeca2f7addc3d17d81fdf777ac3208374a76f8f06ce9abf222", + "input_hex": "70736274ff0100c902000000030d0b15eda761956158644571cb32675916ce29de46c52adb133f80902a7c7bd80000000000ffffffff4f72b07125366b7aa7f09f68a6f02ceb535b6ebb854e679ba3864280827fb2e90000000000ffffffff77f5752181606a93589caf3704005426b2fea128fc7db65cee422db781bcb9170000000000ffffffff028ccff008000000001976a914ec019301c44b9ebb0d9a2488e0f13e9652535f2c88ac8ccff008000000001976a914d873daeb3d98eda8dba55a04e97e0fe87e30a59888ac00000000000100550200000001adde000000000000efbe000000000000000000000000000000000000000000004900000000ffffffff0100e1f505000000001976a9140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b8188ac000000002206032be372801d8460dda52ae178aad774a54800ba56f949c97b8a2f51e299209d060c0f0569430000000000000000000100550200000001adde000000000000efbe000000000000000000000000000000000000000000004900000000ffffffff0100e1f505000000001976a914828b746c9cc6e2fbb89394f54ff40effd2a4b65188ac00000000220602d922903b8eb295bb232fc0304d88cf1755a60179156fa8f84970f0d4072d5fbb0c0f0569430000000001000000000100550200000001adde000000000000efbe000000000000000000000000000000000000000000004900000000ffffffff0100e1f505000000001976a9141188d9744c0fe8e8faec772d0c481bf81400f90988ac000000002206021fb423d42512e49d75a9d27415047a74378243cf7aee07c1b69ba7da0a03d4280c0f05694300000000020000000000220202f2d482365a1e8e9bbfcc830141048e9949f8efbae9b9db6bbfabd85498b5f647100f0569430c000000220000006003000000", + "frames": [ + "B$ZP0200FMUE4KXZZ7EHBEUJQGAYDGMXLP2O34WEVGERCKNOQWTY3URDYXHGTXTTHOVHKW6YXZQYEVSN6UGQMEHYB4CP4RI3BJK43MVLSZ7ZRH5R5SB4527A5C6N3LL65GZRPNZZGU2NK332BFLVR7VVKSYTCILLOLCJZ5PGFQGCC2U37YW5J6CTXMW6NHMT533MMPJ3YXQQVGL2ZZ76AADRETFVNCV4MGOMY6GEPPPG5XSZFIOQ6PW2JUFQVDWX5FMIHLHCI3Y23V5WGPPK5OF5GSFOKZI576FTVA5FGM5NNACNMNSAQBLZQZY62PJQS7Q72PQGVTABGZRVEPB4HL5MGCB3SVONS5P7W7BGVVT557AFA33L63OW43GW5BAYVTCMNLH5", + "B$ZP0201XCUEDNRF4HXFFLMHCWV25FZM6VQNQFPW2PZWI5MXP3QKHGIKOPMXR6CZGOO6CFSQ42IKN3USTQ44OHX53YYXTSSX74XXZ7ZPFXMRMCDTBDJU3JIJ2Z6ZXJXOK3LD6YHA3NYV4PDUDFR2K2H6RIPZ4BI7V2YOXRXPQY5YJELSQ4EHNXBM6HQX74PC26NXEXI6B7UR6IQMH446CDUR36RHYRKV5DE5ZUUVS5FEIWNKJLGJXHGPK66WGP5YNX3PEW24ZRLTJYAOMEBNXIGEYT2OSSUTLGKFZX7M7VTZUGI5LH5GM6X6PC76XZOO3PM7WV67BCM3D5M3XMAEQBYPJAWRAJZQAMEQA" + ] + }, + { + "name": "deflate-fallback", + "description": "Requested compression falls back to Base32 because raw DEFLATE would not reduce a transaction containing deterministic high-entropy scripts.", + "file_type": "T", + "requested_encoding": "Z", + "actual_encoding": "2", + "qr_version": 10, + "input_length": 460, + "input_sha256": "3c20568808c032759c9e1e16598507d1a39b8d6844405fc7ca88c90a8682321c", + "input_hex": "02000000015db79ed3f2c9e09188b69d785d584c512e8921379aa7620f5d306dc977785d2a00000000c820a93e8388aebff3107ee1ebf926eb9db286c04d75acd82e7c571d22017ea67fb93cd6414c10f72668bff618f56b203ffd933ff16a4c75c6ec7b7cc69b66a9a3189dcb0297e867985ffe1bb944ea4a016c4915863e28ecbe78f5546fc950f7200addb40b0428a0660d503f80bbdece59cdd0b8026d1711304282a952b325b4c8322aad53e5f6fd64d5777a043a4f7b72259735a8f82cfe4016559c225be956f69faa43abc4615324e74e187e13a81080de14ce2c691ae5955c08557d00c0b6d300567261c3eb5a99ffffffff010100000000000000c83b26049dd5d85dc2ba75c7f216ec299250f81be76e2bf4a686d14213ecc37667ef88aa7346e3b8c31f72cd95e812ae7cacdd4f50eeacea97e058dda929573ecbe3d38171ce49bcbd96d95bf74f39b7237368a03aef717da692c8973a15c69f534fe41c5d762858a1793f85a291acd457f7307131b6210d4a02bcf71a35d0578b1e2bec78e633a0c65ad562b79fc0055aa173e6c70650a4396dc5c80444019ef2379fecadd5d2d069803fb3cbb77a9be728ba7179c17837bd022b626780218e7d4bb5d7362574579800000000", + "frames": [ + "B$2T0200AIAAAAABLW3Z5U7SZHQJDCFWTV4F2WCMKEXISIJXTKTWED25GBW4S53YLUVAAAAAADECBKJ6QOEK5P7TCB7OD27ZE3VZ3MUGYBGXLLGYFZ6FOHJCAF7KM75ZHTLECTAQ64TGRP7WDD2WWIB77WJT74LKJR24N3D3PTDJWZVJUMMJ3SYCS7UGPGC77YN3SRHKJIAWYSIVQY7CR3F6PD2VI36JKD3SACW5WQFQIKFAMYGVAP4AXPPM4WON2C4AE3IXCEYEFAVJKKZSLNGIGIVK2U7F636WJVLXPICDUT33OISZONNI7AWP4QAWKWOCEW7JK33J7KSDVPCGCUZE45HBQ7QTVAIIBXQUZYWGSGXFSVOAQVL5ADALNUYAKZZGDQ7L", + "B$2T0201LKM7777774AQCAAAAAAAAAAAZA5SMBE52XMF3QV2OXD7EFXMFGJFB6A345XCX5FGQ3IUEE7MYN3GP34IVJZUNY5YYMPXFTMV5AJK47FM3VHVB3VM5KL6AWG5VEUVOPWL4PJYC4OOJG6L3FWZLP3U6ONXENZWRIB255YX3JUSZCLTUFOGT5JU7ZA4LV3CQWFBPE7YLIURVTKFP5ZQOEY3MIINJIBLZ5Y2GXIFPCY6FPWHRZRTUDDFVVLCW6P4ABK2UFZ6NRYGKCSDS3OFZACEIAM66I3Z73FN2XJNA2MAH6Z4XN32TPTSROTRPHAXQN55AIVWEZ4AEGHH2S5V243CK5CXTAAAAAAA" + ] + } + ] +} \ No newline at end of file diff --git a/test_data/deflate-overwide-distance.txt b/test_data/deflate-overwide-distance.txt new file mode 100644 index 0000000..8e077e1 --- /dev/null +++ b/test_data/deflate-overwide-distance.txt @@ -0,0 +1 @@ +B$ZT01005XKQSIYTRIBQB4CEZ5IY5XEWPODKKJSHR2427SJVM7DUU2L2URZI6XDTH5GY3BOJGGM4SLNUDRHWHIVB724Y6YMOYKYONPRDYL5SB736L7RKO7ZOEU5NQWX3CJBUQUB5ECGN62ZUOAA4P2OMVDATT6SXNGV5GNA2ZEMPCZMTJ6VFSFCPUBCERJTKTALA3R3OOJWWS6ESVR2DHWTVQX3CFMCUHJKKRMHXXGBG7IPPJHLZEDOGHLTNSZAPEUIPMGZCVEDXOZWIO63K7R45QETNP2DHPV3WE4Z4XORZHYYFN52TSVLSYCVU7SMPSSEVEVO3A6NJU3N3MEML5XQFHWWTGP4MMJIPFVL4UGAZYSVSUGFEEFKKCG4H2P4LFX2NMAHSRLDFIA3DND6YFJS4L62W5LBHOLAZU4IXW4JKXTGUSTRFND5YH2YP7U5PK7LI3RXRAXB44YH4OJCGMB6IZCIWJJDWTRSMAHSAYXYD5IYYKHYGQYMSB4URFU3FSG5VICHCLLJAF6LBFRVIDUJN5FHDGBFO3SVZMGADDVDWZWUTO47JRAZYQA5CGVM4UPJPZGILOOBEWX6ZL24MK3WKJQWL6SNU6FE2Y5WDQBRYJ27N4FZXOKUHMKJZ25O3IU3IFPEX5NPIOBE76PWCTRYBOF2T2FICAZEXOMBCXLP4TJDJQNHGJAXY6SU37G5GQAKSJE244JQFSX5WTHTRQDKB5A7ZLF7RMSVHRDOJ6TN3VIMW7ZLD3HEI6KZDWJYRXUGVVJB75A4OUQO3TXLBSD54XUVBK254NC47VBRZGHX3ZTP2E4THKPZSMVLCKIY47O3AQDDTPDNAD54TU4HVJLGR4UPI27PBUPWXBVXHPQMQ37AVRCO54F7YKLK67NTOY37ZL77CUTK4UXNJERNJSL53VSFIMG4LA5X7AAXKQKA2FARR3Z6PASGTFVUMAWRC6U5NJNKAS672BLO3JMM22TJQCJ5T7WICVT4WX3CA44NWKGP67X3IXPNV5HTSLUMXOAWPIKVNJLQKR5HOF7PYM73KL2TJ4QNKIUGRYRDND4XPBKDHAX22GX2TW7EOGFPV5QL27Y46DJ4RMGW56P2N6S2N2N5JAHPS4EHIDWZUIXRMEK6TIPW37URXOZTWX3QB4J32ACV5Q6QAWDXWPFVLDBRDFANIXGJT7FNFN45RF4DOYFDZJPMYSY4QWXYUV2BJH2G22FEG6ENIPIXMSOXSTSN24XRWOTGW6P5LBP3HYCRN6SXC7AQ2C7GPIUIRWTE47P2CCZFDO2YLF6FKJP45AEN6K6MH3OJQX3CKB36UZLYIL64UCMTMAJ6ZAI73G3ZMCDUVZQB5UCHIFLQYYJHV4YPJPON6QRO3B2SBKMEVL2J4ZEMWONQD7P2HV36LIWD4H6ZCG3CI7TGVM65HYOI3A2MF22WNMIVXZWQKH4QGU25BWXCOPPZUWGAYRYHYC7GX2SNLXHRLEBISVHOV3A5GKGZ54R7BHIO2DZ2WZ43UEVQTTALLBXXQIYUSOPD6GFQJLYOXWOQR5WG5TWLHXNJLRJEOGNBOWA4ROEYA2UPC5T4SMC2IOYMR3YLN2GGHKS74TFOQ2XKXX2DOU3UGORYWGTGY2RT62H3VYM3M7FIFZGXREBQNVQSUJ4T6DNUIIW35M23YYOWSGUHQMXQKWUCMZTMFKM2KTDLS3WUUQYZWYSLU7KQSNX5AR6GYH6WNZDRD2XLDYDVBY5X4DXIYKHBIHQAAJTD2B4W3BMKN2H2B4KCCKQNZ3XPATXODHEILRWCOYE2KLNB5RM62KCPVT75BCMS4ZXXKCXZBJWIBEWXBLOFBANPVN4NB7TLFZOGYOGQYAB6ED7KVL7DZ7QN2OVKCQDRVZGPSZYEOEVLNO4N23P2AFLYJIL6NWRWZGVHC2BNFNIAEOJZJGCLDTPWPURR73IPEFG67IX7ZCKAV3UU4HYCF6WCCM2MRDZ7RCTBJYFW2UDKC2NWNXOWEFGXMD7EYJ5XTSXPFZJS4T3LECT7XVJ3BDK4DFJX2QQA72TLNTBLL3MTHVXMCFPP75PN2KBJOQMFLXDA4YS4RVYDRCV36RDEFAM2UEAOSWYVM7PC5WDTSFTWXO2VXIRRWMZU4TM7KM7I6VQDLYVJIX7JJSHTPL4E34VSH7SP3SGVRVXOEZHKB4GSRBXP5NL3D2UJQI6KWYLMRFKFTNOFLXPNYZ6CQKPCFZ6SCLASHDYYBOV3CLLL52TCRWFQH7D4VDWZE7WRAXFTFG4GQSYWV6LJS65FVQZ3YEW4DAWFDJX62YJCDENVNG4LE5K7SE35NWM7U6AIQIY6JZDISK6N5COPCRG2XSOT6VI2GHTKD4KHSPXMRB764DLH7HLZCHHVGVJNFPB6TKYTV66XJL7ESODLEG4LPWFBGF52WXQYDFY27ZEFKYCLVXJT3NM624P4JRGT4OFOHLSR5VNMFXNJM7P27ZRHVXG5HTMP254UTFSX5JXHIENRT45GRLQ43FEOUE4CWZPDTSFKYOM3QSFYL4X3TR6LM7AWGTXRP4PINXWAK6TR5NXFFUL3QVGNZJYEEVMFRW524I376B7KHL42LBLCO5XNBIGIOSGYQNOGWY5WX7SN3ICJ4Z7G2KBHVHZOTL3XPCTJTZ5I36CNVKYQCMAE4ZEYRYHRUZQS6OR7Q4GZE5M2EXX236WU3CIIVSF54YW3URKRNNDETLEY5ONFEGI5UMY45ZNEERZ4T6WJ7ZY5TSCXUWUP66WJFE2KHDMMVZOSTS2MONPTGRFPMAAYKD3KMS6TJNCTPDNNVXLM4NK2XKRBAIVVZWVUG6XKW4T5DLN7KEB3ZXB22UE3ZFT57QUEQD3G6UTM2DBPUSC6LHJCRQ5QZ7IKNHHMOHUU5G3JK3ZLXBWMD3TYYLQHCUB777DQM23TNBLPJRVSFW3GRJQ5LXZTSN3X2HV6IDBPJI2RKKUDBACW6DCSNFXMGTJ4B5UOW67DRQ3WOLJZCRCI5XQW5I4PJSIS4HE5G7U6ZX4W5PMM67IK24S4JD3OFLWM6RD7ITMSLXVL64WPPSTCHEJTVQTHSPFYTZYSH2CEXGNOMW57OF4YMZJQGAVNO5QGZVX2M3AAY6A6EKRK752Q32WYZ2PQNCALEL2NG2QK7SRM4SP63SXABCAESMCIOKZMBTNBBTA7OKZR233ILT7NMBFVVQZ335GQFOOOM2WI5U6C4MLF7O5GL3LZPGTFBH5KIKHV7ZPDEWR4O6TCYQC2HKZSYKFYQJQJGNA4DORIOGFYSISLOF4NBTNGPNW53OFMFZK2VTFIZD2Y7ZDCO6CUVAZNVVARQDTZ7RGBO4B7MH2XMZY7WIUNO5IT5XIRYXU3INOG7SSP2P77P7477T777637774HQ \ No newline at end of file From b31951dcf793750d3f15ec09300cb1efd45eda29 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 29 Jul 2026 16:35:23 +0200 Subject: [PATCH 3/4] js: exact 1k DEFLATE window via dmax pin; sync vectors to draft v4 --- js/src/utils.ts | 10 +++-- js/tests/vectors.test.ts | 28 +++++++++++++ test_data/bip-test-vectors.json | 73 ++++++++++++++++++++++++--------- test_data/deflate-dist1024.txt | 1 + test_data/deflate-dist1025.txt | 1 + 5 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 test_data/deflate-dist1024.txt create mode 100644 test_data/deflate-dist1025.txt diff --git a/js/src/utils.ts b/js/src/utils.ts index 83f3cbb..8756a7c 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -234,11 +234,15 @@ export function decodeData(parts: string[], encoding: Encoding, maxSize = DEFAUL } if (encoding === 'Z') { - // small output chunks keep zlib's back-reference distance check close to - // the 1k window implied by wbits=10, and let the size cap apply while - // inflating instead of after the full output has been buffered + // small output chunks let the size cap apply while inflating, instead of + // after the full output has been buffered const inflator = new pako.Inflate({ windowBits: -10, chunkSize: 1024 }); + // pako ships zlib's strict distance check but leaves dmax at 32768 for + // raw streams; pin it to the 1k window so distance >1024 is rejected + // exactly (boundary-tested so a pako upgrade cannot silently break this) + (inflator as any).strm.state.dmax = 1024; + const chunks: Uint8Array[] = []; let total = 0; diff --git a/js/tests/vectors.test.ts b/js/tests/vectors.test.ts index bc431bf..69a285e 100644 --- a/js/tests/vectors.test.ts +++ b/js/tests/vectors.test.ts @@ -25,6 +25,14 @@ describe('BBQr BIP draft vectors', () => { expect(() => joinQRs(c.frames)).toThrow(); }); } + + // draft v4: receivers MAY ignore later duplicates without comparing; + // we compare bodies and fail on conflict - stricter local policy + for (const c of doc.strict_policy_cases) { + test(`reject ${c.name} (strict local policy)`, () => { + expect(() => joinQRs(c.frames)).toThrow(); + }); + } }); describe('DEFLATE resource limits', () => { @@ -37,6 +45,26 @@ describe('DEFLATE resource limits', () => { expect(() => joinQRs([frame])).toThrow(/distance/); }); + test('window boundary: distance 1024 accepted, 1025 rejected', () => { + const ok = readFileSync( + new URL('../../test_data/deflate-dist1024.txt', import.meta.url), + 'utf-8' + ).trim(); + + const { raw } = joinQRs([ok]); + expect(raw.length).toBe(2048); + expect(createHash('sha256').update(raw).digest('hex')).toBe( + 'c30537f307aa7aed41677a596ea4f60de232ff2dc2ef7b478e6ae53e300db05d' + ); + + const bad = readFileSync( + new URL('../../test_data/deflate-dist1025.txt', import.meta.url), + 'utf-8' + ).trim(); + + expect(() => joinQRs([bad])).toThrow(/distance/); + }); + test('cap decompressed size while inflating', () => { const v = doc.vectors.find((v: { name: string }) => v.name === 'deflate-psbt'); diff --git a/test_data/bip-test-vectors.json b/test_data/bip-test-vectors.json index c2213d8..e10f23d 100644 --- a/test_data/bip-test-vectors.json +++ b/test_data/bip-test-vectors.json @@ -4,7 +4,8 @@ "notes": [ "Frames were generated independently by the Python and JavaScript reference implementations and matched exactly.", "All vectors decode in reverse frame order.", - "Raw DEFLATE output is not canonical across compressor implementations; decoders must accept any valid stream constrained to a 1024-byte window." + "Raw DEFLATE output is not canonical across compressor implementations; decoders must accept any valid stream constrained to a 1024-byte window.", + "Duplicate-body comparison is receiver policy. A receiver must not overwrite the first accepted body; it may ignore later duplicates or report a detected mismatch." ], "generated_from": { "coinkite_bbqr_repository": "https://github.com/coinkite/BBQr", @@ -19,72 +20,94 @@ { "name": "zero-total", "reason": "The total frame count is zero.", - "frames": ["B$HP0000AA"] + "frames": [ + "B$HP0000AA" + ] }, { "name": "index-not-less-than-total", "reason": "Index 01 is not valid when the total is 01.", - "frames": ["B$HP0101AA"] + "frames": [ + "B$HP0101AA" + ] }, { "name": "maximum-index-overflow", "reason": "For total ZZ, the last valid index is ZY.", - "frames": ["B$HTZZZZAA"] + "frames": [ + "B$HTZZZZAA" + ] }, { "name": "unknown-encoding", "reason": "Q is not a defined encoding.", - "frames": ["B$QP0100AA"] + "frames": [ + "B$QP0100AA" + ] }, { "name": "empty-body", "reason": "Every frame body is non-empty.", - "frames": ["B$HT0100"] + "frames": [ + "B$HT0100" + ] }, { "name": "odd-hex-body", "reason": "Every H body has even length.", - "frames": ["B$HP0100A"] + "frames": [ + "B$HP0100A" + ] }, { "name": "lowercase-hex-body", "reason": "H uses uppercase hexadecimal.", - "frames": ["B$HT0100aa"] + "frames": [ + "B$HT0100aa" + ] }, { "name": "invalid-base32-terminal-length", "reason": "An unpadded Base32 terminal body cannot have residue one.", - "frames": ["B$2P0100A"] + "frames": [ + "B$2P0100A" + ] }, { "name": "noncanonical-base32-pad-bits", "reason": "AB decodes with non-zero pad bits; the canonical encoding is AA.", - "frames": ["B$2P0100AB"] + "frames": [ + "B$2P0100AB" + ] }, { "name": "missing-frame", "reason": "A two-frame transfer is incomplete without index 01.", - "frames": ["B$HT0200AA"] + "frames": [ + "B$HT0200AA" + ] }, { "name": "mixed-transfer-header", "reason": "The type code changes within one collected transfer.", - "frames": ["B$HT0200AA", "B$HP0201CC"] - }, - { - "name": "conflicting-duplicate", - "reason": "The same index is supplied with two different bodies.", - "frames": ["B$HT0200AA", "B$HT0200BB", "B$HT0201CC"] + "frames": [ + "B$HT0200AA", + "B$HP0201CC" + ] }, { "name": "zlib-wrapper", "reason": "Z requires raw DEFLATE, not a zlib wrapper.", - "frames": ["B$ZP0100PCOEWTCKAYAAETIBE4"] + "frames": [ + "B$ZP0100PCOEWTCKAYAAETIBE4" + ] }, { "name": "deflate-trailing-data", "reason": "Data follows the raw DEFLATE end-of-stream marker.", - "frames": ["B$ZP0100JNGEUBQAAA"] + "frames": [ + "B$ZP0100JNGEUBQAAA" + ] } ], "vectors": [ @@ -148,5 +171,17 @@ "B$2T0201LKM7777774AQCAAAAAAAAAAAZA5SMBE52XMF3QV2OXD7EFXMFGJFB6A345XCX5FGQ3IUEE7MYN3GP34IVJZUNY5YYMPXFTMV5AJK47FM3VHVB3VM5KL6AWG5VEUVOPWL4PJYC4OOJG6L3FWZLP3U6ONXENZWRIB255YX3JUSZCLTUFOGT5JU7ZA4LV3CQWFBPE7YLIURVTKFP5ZQOEY3MIINJIBLZ5Y2GXIFPCY6FPWHRZRTUDDFVVLCW6P4ABK2UFZ6NRYGKCSDS3OFZACEIAM66I3Z73FN2XJNA2MAH6Z4XN32TPTSROTRPHAXQN55AIVWEZ4AEGHH2S5V243CK5CXTAAAAAAA" ] } + ], + "strict_policy_cases": [ + { + "name": "conflicting-duplicate", + "reason": "The same index is supplied with two different bodies.", + "frames": [ + "B$HT0200AA", + "B$HT0200BB", + "B$HT0201CC" + ], + "note": "Draft v4: a receiver MAY ignore a later duplicate without comparing bodies; failing on a detected conflict is stricter local policy, not a normative requirement." + } ] } \ No newline at end of file diff --git a/test_data/deflate-dist1024.txt b/test_data/deflate-dist1024.txt new file mode 100644 index 0000000..35669f9 --- /dev/null +++ b/test_data/deflate-dist1024.txt @@ -0,0 +1 @@ +B$ZB0100ONIWRMXZ7PWOHILMQOPXZR7PGI7OSKBVLHCM7TOLOI4O6BS74GB37ZI3ZIFF4T7BXZVBYP65O3KY3RGZCP7Z6FH4LJZ64SWE536TUD6MQNH657OF75AWZ3WJ6CYBFNVUWLZRXNV45ZSDVYUUPFF6NW5OWTFT7NS53HELB4QVL5UVJDC6SOAIUFWHOP3P56SORO4A2U4UIMBZXQV2U66VR4XPSMK477FD5OXSPNEYG5TN7ZJK4Y6O2JIU6TSFNQNTEL7FGS3OZHGXBSERTCR2EPTPI3QNKRRHC3Q7ZV7BYID5GNQ2MXPE37TDPJ6PU74OMVTSO4AGUQWXOXL3HO34H4GQQYPRLCRC3IXGDINZTWVZV5VMLVTTLNPCS6V6SEURBOVIPPA52ZMR2VBP2LWNQNO55JK27OPYOQ3HNPOLKYRASVOXQOWJ7BKWA6NNYOZYVXY4WQXDVRCSFPFM2MLT6O5R4CGIFQVCS6LA26I7W224IZWNJWW5XIOZV75VI33NZHY3TIWL4ML43LS5NWWF6W6AKVZVNNKTIWGZW5OK6Y7R77EMS6VUORH3PAWJ4YZWZUQ5GQ3AMFTPJTF3AEJHLDIKJ7XDIPDYYF5GU3LYZMHZ5K446JN52HG4LJ5E7H4N2AW77QI4WXUEC4OVQK7V7CZPV7USTECQ3EJZQML7Y3GH2ILPWXWQ6RXZG7OMVF72PNZNHEIQCG6SNYYOFV343R6VQ7XZ4DSZ63AOCZ4O5WOYWNWT7PVA6MRFH3QOFL3JZWGJR2E52Y637YKJ3C3N7V7NWKHGITBU46YWDG7OVK3H77X5NVFRT3A7HXNTZZ6M3I7ZMBYPM4YPNJN6XE4CP7CZ5IHMNY5XZ2XVD6STG6C6XAX75GXGQVOWJXWZOZX3XZU7SEPWL26MX2EG6JLKKR24EFA7UZ33M5TWKVNO5JKFNW7TLYZGYRWXW2ZBGZZCX232YKDMVHST7N7INZ6WPITKZBR67NWXYFXG4D3XZQ5VW3M5HFD4TLHE6SJD7RXR4IZNY6MCP4P2335RREYCQPCT7335TQNQWKFIS34O446G6WPZZ4RFOQ44TXU6XYRDSNJ44BH26KZR744SHXKOJJHRMY3LXEF4SNUI7TK5XVCL572OPVEU7D5P5BWK3KMVRPOHYW36ZPS3JG6KDWVA2UMGXZZBTN6FUT2T53BVLIQOEIHQZDY5IE46F26YMZO2WMCI7SFWFZYGUPLEHXN6AZTN43FLKJ56TE5D3ZM7DKILN57YGLBYWGY4CTCWMLVYHJR7DFXOMUWHMPULSYWVO7W67724LLSDDIZC4EV7DNVNP3W4PNXO5W3VUF6N4DMFF2LHDFZERU5MYMS2SU2FJ4ZD5ZZO76JZWEQO2T6FZXT3WE56VE3F6SDHHH2FH7O4JWIS3L4YCNTTFWOOLI4ODUWFXHHWOOJ7OYHW4ZFZFG2OOORQPXVOSMI55F6NXPPP3ONGGL5NX5X7LOXCZUM5DOWHOHZOLFWJ25565LX6SH37X3CNHUC2OTE7KJPJGRX7V5DGPYQZOVXUTZO73AUGC4WNU26S76VLISXM44P3U7J75HNU7ZVOM4ZUHW2PT2XEH64WW7UYWBWOZEDZHDJ3RNFHVPXJFN2TOPZAKWM7ZNO72X7HOT6M57N745B7LWVITBTT5MWLLRZRP7XZXLBMNUN5XFMFOMWCXI4G33UN4VGFY4YKWSF7VV3VD3RLM4YZZX72H6L7GT7Y7WH4B4AA \ No newline at end of file diff --git a/test_data/deflate-dist1025.txt b/test_data/deflate-dist1025.txt new file mode 100644 index 0000000..d339a43 --- /dev/null +++ b/test_data/deflate-dist1025.txt @@ -0,0 +1 @@ +B$ZB01004XKASNY3AYAAAYGH3IWRCQWWJDO6MWDIDTGYRCBLVBKG25NWCNZYOF33R262ABHRTTGUGUWPT3LLDOCTFV2WL4KQIER2JVE5I6RZDGXBGWUKHQOLJCEE552D636YRT6QB3AQS3JMJSAQJL6NRRFGQEFN4HBVNPXYPZ4DMGIU3YLINY2ENUWQ6DYMNWC4Y7ZKKBLHYGOY7XKLO546233DH3NUVF7LCOKJHHZD32ZLQKJ3DXVDBQTVYWK6WKYVRW3IRAJVMAF24FRANOQG4QKXWBKFVNGOVZX3IPKNRGGH3VFHBYZWVR2CCQHWYCPOSCG262NDS37XMNIUWRAAL6RSFHK3EF4OILSZHM7UWYSZNCO4C6JIVZIPHB2MFSCTRW7EHI5ABI6IFAJEROW3YRHUSXT7B7KBORO6HQOQDOENKSGTMAUEB2UANYRXD6G57HSJSMWNKKSBUMG7BVELHBL55NGUS466WUPM4FQ52NM5QIBB3226KYG65X7B2EKCPIUL3JMXFEESXKFEAWV4ZTY6NSV6XN5EZTMKHP7GPTU75Q77LNLJGC2A4TETXVMZ3FPYRQQCDOD7JHKI3OUVZG7EK4VX23X7PWSRFFSZKWCTXHBJM3PW2YPVRM525STAWG7PVDEIMNFJYPI5VRGF5KJEA2CPBPT7N6GYYGSCG7JNU5XIGWW7ONSZSCI46MY64PHM56XVA2UYAC4YK637WSDA6R5XOBFRTYZIZVNU22JUJJ54YOWS6FMWP6TGVBKTO2MH73AF3B6ARSVGAIOY3YV5K5DJ565SVUISIQMJRIDWWKPKNPPXQSKWD5WYHUH2MQ242O5SU56HGMUEBIMBBPF3H2GEUQ7Y32HBR2TZI65JT7H6XUILNBRH3WLTCVBU6GCWZ5OKC3NDMQDDCLP2E2T6UONFQQLX6YN5KT4VMEKQ3YAV656NMD2JFOJCHIAOJEPIC3X5GV55WXNHQJI6KLWBROWVKFG6NFZ5UVV77Q2YASEOLIPJEKXWJWCCFBHA4JTNSHYNZSEC3WM2EV3TPWRBWY6ZYKIGKRPAYLOXXSCLYWDRQXAZ6ETSNOKWLBDGVVFATW7E4CHDKCPCT7CEU6EUOWTSZN45QULGWY42SOEXIC6R2AZJB2C3KREJ5UIG55VF2WIJOH532XAV2HI37QZXRMTS3KZ5LPI47Y3WNMYQZF53KU235LKOH4VZ364CN2TOAR3RPC562OY4FCGZS43YPGZ3IVHA6OUU2ETQY4GQKN5P4QFHMDN3Z7YS4NHXPQDAUJR56TL37U4BPD4P7C2ZAWYDSYWE4ME3FYMQUMCBCWOXKELWICGE7AKIGCKQDOBDLM6U2TQW7QF2BZ7ZBO3I7NMO2WPYTJQJDNUWOTBUOQEZ4EBSPUUTGEZHFHVTLJH3BONZC667HYOOC3QPFWSKWJSTXF7AP4OBZENHQQNIS2XXH2EHRNUGGDOB63JUK3L5RLQNVXDHZXKNZG2TGLBP2TWXL2WH4VPZEFBVSUAG5ZTD7NJXLK3VKRMB5UAHRCG4EC3CHYMEOXNKP452MHDKMJS25AZNKJPYMVZDA6TIPEZVGITH2NBN7HCDAPYBUR7WZDLBZQ7LHODZ2XAVRP4HSVCPWPZY45LS532QBPZLTDGCOSNJULLNRI3QFMUANZEQKCGEAH6OU6R466QFLPHNKET6UKV3VFABM724NHKZBSF427RL5AZ6TSS63NCBJEQM773QD7AH \ No newline at end of file From df417ad5629b66ae9b85b3152d8153f7152d3634 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 30 Jul 2026 10:15:48 +0200 Subject: [PATCH 4/4] js: validate maxSize; pin pako to exact version --- js/package-lock.json | 2 +- js/package.json | 2 +- js/src/utils.ts | 5 +++++ js/tests/vectors.test.ts | 8 ++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/js/package-lock.json b/js/package-lock.json index 02a4016..9f73d5a 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -12,7 +12,7 @@ "@types/pako": "^2.0.3", "@types/qrcode": "^1.5.5", "@types/upng-js": "^2.1.5", - "pako": "^2.1.0", + "pako": "2.1.0", "qrcode": "^1.5.4", "typescript": "^5.8.2", "upng-js": "^2.1.0", diff --git a/js/package.json b/js/package.json index 3350d2f..d85236e 100644 --- a/js/package.json +++ b/js/package.json @@ -31,7 +31,7 @@ "@types/pako": "^2.0.3", "@types/qrcode": "^1.5.5", "@types/upng-js": "^2.1.5", - "pako": "^2.1.0", + "pako": "2.1.0", "qrcode": "^1.5.4", "typescript": "^5.8.2", "upng-js": "^2.1.0", diff --git a/js/src/utils.ts b/js/src/utils.ts index 8756a7c..1eb07fa 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -199,6 +199,11 @@ export function encodeData(raw: Uint8Array, encoding?: Encoding) { export function decodeData(parts: string[], encoding: Encoding, maxSize = DEFAULT_MAX_SIZE) { // decode the parts back into a Uint8Array + // NaN would compare false against every length and disable the caps + if (!Number.isSafeInteger(maxSize) || maxSize <= 0) { + throw new RangeError('maxSize must be a positive safe integer'); + } + if (encoding === 'H') { const raw = joinByteParts( parts.map((p) => { diff --git a/js/tests/vectors.test.ts b/js/tests/vectors.test.ts index 69a285e..534cb6e 100644 --- a/js/tests/vectors.test.ts +++ b/js/tests/vectors.test.ts @@ -65,6 +65,14 @@ describe('DEFLATE resource limits', () => { expect(() => joinQRs([bad])).toThrow(/distance/); }); + test('reject invalid maxSize', () => { + const v = doc.vectors[0]; + + for (const bad of [NaN, 0, -1, 1.5, Infinity]) { + expect(() => joinQRs(v.frames, bad)).toThrow(RangeError); + } + }); + test('cap decompressed size while inflating', () => { const v = doc.vectors.find((v: { name: string }) => v.name === 'deflate-psbt');