streaming RAR extractor
jsr.io/@mary/rar
jsr
1/**
2 * @module
3 *
4 * one-shot builder for `tests/fixtures/rar3-long-password.rar`. constructs a
5 * single-entry RAR3 archive whose body is AES-128-CBC encrypted with the
6 * rarbug-flavoured KDF, using a 32-character password (66-byte seed) that
7 * activates the in-place SHA-1 corruption. system `unrar` has been used as
8 * an oracle to confirm the resulting archive decrypts to the expected
9 * plaintext.
10 *
11 * run: `deno run -A scripts/build-rar3-long-password-fixture.ts`
12 */
13
14import { crc32 } from '@mary/crc32';
15
16import { rar3DeriveKey } from '../lib/rar3/crypto.ts';
17import { importAesKey } from '../lib/rar5/crypto.ts';
18import { BlockFlag, BlockType, CompressMethod, FileFlag, HostOs } from '../lib/rar3/constants.ts';
19
20const PASSWORD = 'the-rarbug-makes-this-key-funny!';
21const SALT = new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
22const NAME = 'hello.txt';
23const PLAINTEXT = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz012345');
24
25// 2026-05-27 12:00:00 UTC, DOS-encoded: year-1980=46, month=5, day=27, hour=12.
26const MTIME = ((46 << 25) | (5 << 21) | (27 << 16) | (12 << 11)) >>> 0;
27
28const writeU16LE = (buf: Uint8Array, offset: number, value: number): void => {
29 buf[offset + 0] = value & 0xff;
30 buf[offset + 1] = (value >>> 8) & 0xff;
31};
32
33const writeU32LE = (buf: Uint8Array, offset: number, value: number): void => {
34 buf[offset + 0] = value & 0xff;
35 buf[offset + 1] = (value >>> 8) & 0xff;
36 buf[offset + 2] = (value >>> 16) & 0xff;
37 buf[offset + 3] = (value >>> 24) & 0xff;
38};
39
40const sealHeader = (header: Uint8Array, crcEnd: number): Uint8Array => {
41 const crc = crc32(header.subarray(2, crcEnd)) & 0xffff;
42 writeU16LE(header, 0, crc);
43 return header;
44};
45
46const buildMainBlock = (): Uint8Array => {
47 const header = new Uint8Array(13);
48 header[2] = BlockType.main;
49 writeU16LE(header, 3, 0x0000);
50 writeU16LE(header, 5, 13);
51 // 6 reserved bytes already zero.
52 return sealHeader(header, 13);
53};
54
55const buildFileBlock = (ciphertextLength: number, plaintextCrc: number): Uint8Array => {
56 const nameBytes = new TextEncoder().encode(NAME);
57 const headerSize = 7 + 25 + nameBytes.length + SALT.length;
58 const header = new Uint8Array(headerSize);
59
60 header[2] = BlockType.file;
61 const flags = BlockFlag.longBlock | FileFlag.password | FileFlag.salt;
62 writeU16LE(header, 3, flags);
63 writeU16LE(header, 5, headerSize);
64 writeU32LE(header, 7, ciphertextLength); // packed_size — overlays add_size
65 writeU32LE(header, 11, PLAINTEXT.length); // unpacked_size
66 header[15] = HostOs.msdos;
67 writeU32LE(header, 16, plaintextCrc);
68 writeU32LE(header, 20, MTIME);
69 header[24] = 29; // extract version
70 header[25] = CompressMethod.stored;
71 writeU16LE(header, 26, nameBytes.length);
72 writeU32LE(header, 28, 0x00000020); // DOS archive attribute
73 header.set(nameBytes, 32);
74 header.set(SALT, 32 + nameBytes.length);
75
76 return sealHeader(header, headerSize);
77};
78
79const buildEndArcBlock = (): Uint8Array => {
80 const header = new Uint8Array(7);
81 header[2] = BlockType.endArc;
82 writeU16LE(header, 3, 0x0000);
83 writeU16LE(header, 5, 7);
84 return sealHeader(header, 7);
85};
86
87const encryptBody = async (): Promise<Uint8Array> => {
88 const { iv, key } = rar3DeriveKey(PASSWORD, SALT);
89
90 const aes = await importAesKey(key);
91
92 // pad plaintext with PKCS#7 so web crypto encrypts cleanly, then drop the
93 // trailing pad block. RAR3 ciphertext length matches the padded plaintext
94 // length up to (but not past) the 16-byte boundary.
95 const ct = new Uint8Array(
96 await crypto.subtle.encrypt({ iv, name: 'AES-CBC' }, aes, PLAINTEXT),
97 );
98 const aligned = (PLAINTEXT.length + 15) & ~15;
99 return ct.subarray(0, aligned);
100};
101
102const main = async (): Promise<void> => {
103 const ciphertext = await encryptBody();
104 const plaintextCrc = crc32(PLAINTEXT);
105
106 const signature = new Uint8Array([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]);
107 const main = buildMainBlock();
108 const file = buildFileBlock(ciphertext.length, plaintextCrc);
109 const endArc = buildEndArcBlock();
110
111 const total = signature.length + main.length + file.length + ciphertext.length + endArc.length;
112 const archive = new Uint8Array(total);
113 let pos = 0;
114 for (const part of [signature, main, file, ciphertext, endArc]) {
115 archive.set(part, pos);
116 pos += part.length;
117 }
118
119 const outPath = new URL('../tests/fixtures/rar3-long-password.rar', import.meta.url);
120 Deno.writeFileSync(outPath, archive);
121 console.log(`wrote ${total}-byte fixture to ${outPath.pathname}`);
122 console.log(` password: ${PASSWORD} (${PASSWORD.length} chars)`);
123 console.log(` name: ${NAME}`);
124 console.log(` plaintext: ${PLAINTEXT.length} bytes, crc32 0x${plaintextCrc.toString(16)}`);
125};
126
127await main();