Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions js/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 37 additions & 3 deletions js/src/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,32 @@
* QR code decoding/joining.
*/

import { ENCODINGS } from './consts';
import { DEFAULT_MAX_SIZE, 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.
*
* @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 {
Comment thread
scgbckbone marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -45,6 +60,7 @@ export function joinQRs(parts: string[]): JoinResult {
}

const data = new Map<number, string>();
let bodyLen: number | null = null;

for (const p of parts) {
const idx = parseInt(p.slice(6, 8), 36);
Expand All @@ -58,6 +74,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 = [];
Expand All @@ -72,7 +97,16 @@ export function joinQRs(parts: string[]): JoinResult {
orderedParts.push(p);
}

const raw = decodeData(orderedParts, encoding);
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, maxSize);

if (!raw.length) {
throw new Error('empty transfer');
}

return { fileType, encoding, raw };
}
Expand Down
78 changes: 73 additions & 5 deletions js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

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]*$/;

export function hexToBytes(hex: string) {
// convert a hex string to a Uint8Array

Expand Down Expand Up @@ -194,23 +196,89 @@ 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

// 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') {
return joinByteParts(parts.map((p) => hexToBytes(p)));
const raw = joinByteParts(
parts.map((p) => {
if (p.length % 2 || !HEX_RE.test(p)) {
throw new Error('non-canonical hex body');
}

return hexToBytes(p);
})
);

if (raw.length > maxSize) {
throw new Error('decoded data too large');
}

return raw;
}

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));
})
);

if (bytes.length > maxSize) {
throw new Error('decoded data too large');
}

if (encoding === 'Z') {
return pako.inflate(bytes, { windowBits: -10 });
// 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;
Comment thread
scgbckbone marked this conversation as resolved.

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);
Comment thread
scgbckbone marked this conversation as resolved.

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 joinByteParts(chunks);
}

return bytes;
Expand Down
83 changes: 83 additions & 0 deletions js/tests/vectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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();
});
}

// 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', () => {
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('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('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');

// 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);
});
});
Loading