rn-pdf-decrypt 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,886 @@
1
+ /**
2
+ * rn-pdf-decrypt — PDF decryption with AES-256 and RC4 support
3
+ * React Native compatible fork of @pdfsmaller/pdf-decrypt
4
+ *
5
+ * @author imdewan (https://github.com/imdewan/rn-pdf-decrypt)
6
+ * @license MIT
7
+ * @see https://github.com/imdewan/rn-pdf-decrypt
8
+ *
9
+ * Implements:
10
+ * - AES-256 (V=5, R=5) per Adobe Supplement to ISO 32000 — SHA-256 based
11
+ * - AES-256 (V=5, R=6) per ISO 32000-2:2020 — Algorithms 2.A, 2.B, 11, 12, 13
12
+ * - RC4 128-bit (V=2, R=3) per ISO 32000-1:2008 — Algorithms 2, 4, 5, 7
13
+ * - RC4 40-bit (V=1, R=2) per ISO 32000-1:2008
14
+ *
15
+ * Fork of @pdfsmaller/pdf-decrypt with @noble/hashes + @noble/ciphers for React Native (Hermes) compatibility
16
+ * Verified against mozilla/pdf.js and Adobe Acrobat
17
+ */
18
+
19
+ import { PDFDocument, PDFName, PDFHexString, PDFString, PDFDict, PDFArray, PDFRawStream, PDFNumber, PDFRef } from 'pdf-lib';
20
+ import { md5, RC4, hexToBytes, bytesToHex } from './crypto-rc4.js';
21
+ import {
22
+ sha256,
23
+ aes256CbcDecrypt, aes256CbcDecryptNoPad, aes256EcbDecryptBlock,
24
+ importAES256DecryptKey, aes256CbcDecryptWithKey,
25
+ computeHash2B, concat
26
+ } from './crypto-aes.js';
27
+
28
+ // ========== Constants ==========
29
+
30
+ // Standard PDF padding string (from PDF specification)
31
+ const PADDING = new Uint8Array([
32
+ 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41,
33
+ 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08,
34
+ 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80,
35
+ 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A
36
+ ]);
37
+
38
+ // Batch size for parallel AES-256 decryption
39
+ const BATCH_SIZE = 100;
40
+
41
+ // ========== Helper Functions ==========
42
+
43
+ /**
44
+ * Compare two Uint8Arrays for equality
45
+ */
46
+ function arraysEqual(a, b) {
47
+ if (a.length !== b.length) return false;
48
+ for (let i = 0; i < a.length; i++) {
49
+ if (a[i] !== b[i]) return false;
50
+ }
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Extract bytes from a PDF string value (handles both hex and literal strings)
56
+ */
57
+ function extractBytes(pdfObj) {
58
+ if (!pdfObj) return null;
59
+
60
+ if (pdfObj instanceof PDFHexString) {
61
+ return hexToBytes(pdfObj.asString());
62
+ }
63
+
64
+ if (pdfObj instanceof PDFString) {
65
+ return pdfObj.asBytes();
66
+ }
67
+
68
+ // Handle raw string representation (fallback)
69
+ const str = pdfObj.toString();
70
+ if (str.startsWith('<') && str.endsWith('>')) {
71
+ return hexToBytes(str.slice(1, -1));
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Truncate password to 127 bytes (UTF-8) per PDF 2.0 spec
79
+ */
80
+ function saslPrepPassword(password) {
81
+ const bytes = new TextEncoder().encode(password);
82
+ return bytes.length > 127 ? bytes.slice(0, 127) : bytes;
83
+ }
84
+
85
+ // ========== Encryption Parameter Reading ==========
86
+
87
+ /**
88
+ * Read the encryption parameters from a PDF's trailer
89
+ * Supports V=1-2 (RC4) and V=5 (AES-256)
90
+ */
91
+ function readEncryptParams(context) {
92
+ const trailer = context.trailerInfo;
93
+
94
+ // Get /Encrypt reference
95
+ const encryptRef = trailer.Encrypt;
96
+ if (!encryptRef) {
97
+ return null;
98
+ }
99
+
100
+ // Resolve the encrypt dictionary
101
+ let encryptDict;
102
+ if (encryptRef instanceof PDFRef) {
103
+ encryptDict = context.lookup(encryptRef);
104
+ } else if (encryptRef instanceof PDFDict) {
105
+ encryptDict = encryptRef;
106
+ } else {
107
+ return null;
108
+ }
109
+
110
+ if (!encryptDict || !(encryptDict instanceof PDFDict)) {
111
+ return null;
112
+ }
113
+
114
+ // Read basic encryption parameters
115
+ const V = encryptDict.get(PDFName.of('V'));
116
+ const R = encryptDict.get(PDFName.of('R'));
117
+ const Length = encryptDict.get(PDFName.of('Length'));
118
+ const P = encryptDict.get(PDFName.of('P'));
119
+ const O = encryptDict.get(PDFName.of('O'));
120
+ const U = encryptDict.get(PDFName.of('U'));
121
+
122
+ const version = V ? (typeof V.asNumber === 'function' ? V.asNumber() : Number(V.toString())) : 0;
123
+ const revision = R ? (typeof R.asNumber === 'function' ? R.asNumber() : Number(R.toString())) : 0;
124
+
125
+ // Extract permissions (signed 32-bit integer)
126
+ const permissions = P ? (typeof P.asNumber === 'function' ? P.asNumber() : Number(P.toString())) : 0;
127
+
128
+ // Extract O and U values
129
+ const ownerKey = extractBytes(O);
130
+ const userKey = extractBytes(U);
131
+
132
+ if (!ownerKey || !userKey) {
133
+ throw new Error('Could not read /O or /U values from encryption dictionary');
134
+ }
135
+
136
+ // Extract file ID
137
+ let fileId = new Uint8Array(0);
138
+ const idArray = trailer.ID;
139
+
140
+ if (idArray) {
141
+ if (Array.isArray(idArray) && idArray.length > 0) {
142
+ fileId = extractBytes(idArray[0]) || new Uint8Array(0);
143
+ } else if (idArray instanceof PDFArray) {
144
+ const firstId = idArray.lookup(0);
145
+ fileId = extractBytes(firstId) || new Uint8Array(0);
146
+ }
147
+ }
148
+
149
+ // Base params (shared by RC4 and AES-256)
150
+ const params = {
151
+ version,
152
+ revision,
153
+ ownerKey,
154
+ userKey,
155
+ permissions,
156
+ fileId,
157
+ encryptRef,
158
+ encryptDict
159
+ };
160
+
161
+ // V=5, R=5/6: AES-256 specific fields
162
+ if (version === 5 && (revision === 5 || revision === 6)) {
163
+ const OE = encryptDict.get(PDFName.of('OE'));
164
+ const UE = encryptDict.get(PDFName.of('UE'));
165
+ const Perms = encryptDict.get(PDFName.of('Perms'));
166
+ const EncryptMetadata = encryptDict.get(PDFName.of('EncryptMetadata'));
167
+
168
+ params.ownerEncryptKey = extractBytes(OE);
169
+ params.userEncryptKey = extractBytes(UE);
170
+ params.perms = extractBytes(Perms);
171
+
172
+ if (!params.ownerEncryptKey || !params.userEncryptKey || !params.perms) {
173
+ throw new Error('Missing /OE, /UE, or /Perms in AES-256 encryption dictionary');
174
+ }
175
+
176
+ // EncryptMetadata defaults to true per spec
177
+ if (EncryptMetadata) {
178
+ const emStr = EncryptMetadata.toString();
179
+ params.encryptMetadata = emStr !== 'false';
180
+ } else {
181
+ params.encryptMetadata = true;
182
+ }
183
+
184
+ params.algorithm = 'AES-256';
185
+ params.keyLength = 32;
186
+ } else if (version <= 3 && revision <= 4) {
187
+ // RC4 (V=1-2, R=2-3)
188
+ let keyLengthBits = Length ? (typeof Length.asNumber === 'function' ? Length.asNumber() : Number(Length.toString())) : 40;
189
+ if (revision >= 3 && !Length) keyLengthBits = 128;
190
+ params.keyLength = keyLengthBits / 8;
191
+ params.algorithm = 'RC4';
192
+ } else {
193
+ throw new Error(
194
+ `Unsupported encryption: V=${version}, R=${revision}. ` +
195
+ `pdf-decrypt supports RC4 (V=1-2, R=2-3) and AES-256 (V=5, R=5/6).`
196
+ );
197
+ }
198
+
199
+ return params;
200
+ }
201
+
202
+ // ========== RC4 Password Validation & Decryption ==========
203
+
204
+ /**
205
+ * Pad or truncate password according to PDF spec (RC4)
206
+ */
207
+ function padPassword(password) {
208
+ const pwdBytes = typeof password === 'string' ? new TextEncoder().encode(password) : password;
209
+ const padded = new Uint8Array(32);
210
+
211
+ if (pwdBytes.length >= 32) {
212
+ padded.set(pwdBytes.slice(0, 32));
213
+ } else {
214
+ padded.set(pwdBytes);
215
+ padded.set(PADDING.slice(0, 32 - pwdBytes.length), pwdBytes.length);
216
+ }
217
+
218
+ return padded;
219
+ }
220
+
221
+ /**
222
+ * Compute encryption key (Algorithm 2 from PDF spec)
223
+ * Works for both Rev 2 (RC4-40) and Rev 3 (RC4-128)
224
+ */
225
+ function computeEncryptionKey(password, ownerKey, permissions, fileId, revision, keyLength) {
226
+ const paddedPwd = padPassword(password);
227
+
228
+ const hashInput = new Uint8Array(
229
+ paddedPwd.length +
230
+ ownerKey.length +
231
+ 4 + // permissions
232
+ fileId.length
233
+ );
234
+
235
+ let offset = 0;
236
+ hashInput.set(paddedPwd, offset);
237
+ offset += paddedPwd.length;
238
+
239
+ hashInput.set(ownerKey, offset);
240
+ offset += ownerKey.length;
241
+
242
+ // Add permissions (low-order byte first)
243
+ hashInput[offset++] = permissions & 0xFF;
244
+ hashInput[offset++] = (permissions >> 8) & 0xFF;
245
+ hashInput[offset++] = (permissions >> 16) & 0xFF;
246
+ hashInput[offset++] = (permissions >> 24) & 0xFF;
247
+
248
+ hashInput.set(fileId, offset);
249
+
250
+ let hash = md5(hashInput);
251
+
252
+ // For Rev 3+, do 50 additional MD5 iterations
253
+ if (revision >= 3) {
254
+ const n = keyLength;
255
+ for (let i = 0; i < 50; i++) {
256
+ hash = md5(hash.slice(0, n));
257
+ }
258
+ }
259
+
260
+ return hash.slice(0, keyLength);
261
+ }
262
+
263
+ /**
264
+ * Validate user password — Algorithm 4 (Rev 2) / Algorithm 5 (Rev 3)
265
+ * Returns the encryption key if valid, null if invalid
266
+ */
267
+ function validateUserPasswordRC4(password, encryptParams) {
268
+ const { ownerKey, userKey, permissions, fileId, revision, keyLength } = encryptParams;
269
+
270
+ const encryptionKey = computeEncryptionKey(password, ownerKey, permissions, fileId, revision, keyLength);
271
+
272
+ if (revision === 2) {
273
+ // Algorithm 4: RC4 encrypt the padding with the key, compare to /U
274
+ const rc4 = new RC4(encryptionKey);
275
+ const computed = rc4.process(new Uint8Array(PADDING));
276
+
277
+ if (arraysEqual(computed, userKey)) {
278
+ return encryptionKey;
279
+ }
280
+ } else {
281
+ // Algorithm 5 (Rev 3):
282
+ // 1. MD5(PADDING + fileId)
283
+ const hashInput = new Uint8Array(PADDING.length + fileId.length);
284
+ hashInput.set(PADDING);
285
+ hashInput.set(fileId, PADDING.length);
286
+ const hash = md5(hashInput);
287
+
288
+ // 2. RC4 encrypt with key, then 19 more iterations with key XOR i
289
+ let result = new RC4(encryptionKey).process(hash);
290
+ for (let i = 1; i <= 19; i++) {
291
+ const iterKey = new Uint8Array(encryptionKey.length);
292
+ for (let j = 0; j < encryptionKey.length; j++) {
293
+ iterKey[j] = encryptionKey[j] ^ i;
294
+ }
295
+ result = new RC4(iterKey).process(result);
296
+ }
297
+
298
+ // 3. Compare first 16 bytes with stored /U
299
+ if (arraysEqual(result.slice(0, 16), userKey.slice(0, 16))) {
300
+ return encryptionKey;
301
+ }
302
+ }
303
+
304
+ return null;
305
+ }
306
+
307
+ /**
308
+ * Validate owner password — Algorithm 7 (Rev 3) / simplified for Rev 2
309
+ * Recovers the user password from the owner password, then validates as user
310
+ * Returns the encryption key if valid, null if invalid
311
+ */
312
+ function validateOwnerPasswordRC4(ownerPassword, encryptParams) {
313
+ const { ownerKey, revision, keyLength } = encryptParams;
314
+
315
+ // Step 1: Pad the owner password
316
+ const paddedOwner = padPassword(ownerPassword);
317
+
318
+ // Step 2: MD5 hash
319
+ let hash = md5(paddedOwner);
320
+
321
+ // Step 3: For Rev 3+, 50 additional MD5 iterations
322
+ if (revision >= 3) {
323
+ for (let i = 0; i < 50; i++) {
324
+ hash = md5(hash);
325
+ }
326
+ }
327
+
328
+ const ownerDecryptKey = hash.slice(0, keyLength);
329
+
330
+ // Step 4: Decrypt /O to recover user password
331
+ let recoveredUserPwd;
332
+
333
+ if (revision === 2) {
334
+ // Simple RC4 decrypt
335
+ const rc4 = new RC4(ownerDecryptKey);
336
+ recoveredUserPwd = rc4.process(new Uint8Array(ownerKey));
337
+ } else {
338
+ // Rev 3: Reverse the 20-iteration RC4 (i = 19 → 0)
339
+ let result = new Uint8Array(ownerKey);
340
+ for (let i = 19; i >= 0; i--) {
341
+ const iterKey = new Uint8Array(ownerDecryptKey.length);
342
+ for (let j = 0; j < ownerDecryptKey.length; j++) {
343
+ iterKey[j] = ownerDecryptKey[j] ^ i;
344
+ }
345
+ result = new RC4(iterKey).process(result);
346
+ }
347
+ recoveredUserPwd = result;
348
+ }
349
+
350
+ // Step 5: Use recovered user password to validate
351
+ return validateUserPasswordRC4(recoveredUserPwd, encryptParams);
352
+ }
353
+
354
+ /**
355
+ * Decrypt data for a specific object using RC4
356
+ */
357
+ function decryptObjectRC4(data, objectNum, generationNum, encryptionKey) {
358
+ // Create object-specific key
359
+ const keyInput = new Uint8Array(encryptionKey.length + 5);
360
+ keyInput.set(encryptionKey);
361
+
362
+ // Add object number (low byte first)
363
+ keyInput[encryptionKey.length] = objectNum & 0xFF;
364
+ keyInput[encryptionKey.length + 1] = (objectNum >> 8) & 0xFF;
365
+ keyInput[encryptionKey.length + 2] = (objectNum >> 16) & 0xFF;
366
+
367
+ // Add generation number (low byte first)
368
+ keyInput[encryptionKey.length + 3] = generationNum & 0xFF;
369
+ keyInput[encryptionKey.length + 4] = (generationNum >> 8) & 0xFF;
370
+
371
+ // Hash to get object key
372
+ const objectKey = md5(keyInput);
373
+
374
+ // Use up to 16 bytes of the hash as the key
375
+ const rc4 = new RC4(objectKey.slice(0, Math.min(encryptionKey.length + 5, 16)));
376
+
377
+ return rc4.process(data);
378
+ }
379
+
380
+ /**
381
+ * Recursively decrypt strings in a PDF object (RC4 mode)
382
+ */
383
+ function decryptStringsRC4(obj, objectNum, generationNum, encryptionKey) {
384
+ if (!obj) return;
385
+
386
+ if (obj instanceof PDFString) {
387
+ const originalBytes = obj.asBytes();
388
+ const decrypted = decryptObjectRC4(originalBytes, objectNum, generationNum, encryptionKey);
389
+ // Convert bytes back to string via charCode (NOT bytesToHex)
390
+ obj.value = Array.from(decrypted).map(b => String.fromCharCode(b)).join('');
391
+ } else if (obj instanceof PDFHexString) {
392
+ const originalBytes = obj.asBytes();
393
+ const decrypted = decryptObjectRC4(originalBytes, objectNum, generationNum, encryptionKey);
394
+ obj.value = bytesToHex(decrypted);
395
+ } else if (obj instanceof PDFDict) {
396
+ const entries = obj.entries();
397
+ for (const [key, value] of entries) {
398
+ const keyName = key.asString();
399
+ // Skip encryption-related entries
400
+ if (keyName !== '/Length' && keyName !== '/Filter' && keyName !== '/DecodeParms') {
401
+ decryptStringsRC4(value, objectNum, generationNum, encryptionKey);
402
+ }
403
+ }
404
+ } else if (obj instanceof PDFArray) {
405
+ const array = obj.asArray();
406
+ for (const element of array) {
407
+ decryptStringsRC4(element, objectNum, generationNum, encryptionKey);
408
+ }
409
+ }
410
+ }
411
+
412
+ // ========== AES-256 Password Validation ==========
413
+
414
+ /**
415
+ * Compute the password hash for AES-256, dispatching to the correct algorithm
416
+ * based on revision:
417
+ * - R=5 (Adobe extension): SHA-256(password + salt + extra)
418
+ * - R=6 (ISO 32000-2): Algorithm 2.B (iterative hash)
419
+ */
420
+ async function aes256PasswordHash(password, salt, extra, revision) {
421
+ if (revision === 5) {
422
+ return sha256(concat(password, salt, extra));
423
+ }
424
+ return computeHash2B(password, salt, extra);
425
+ }
426
+
427
+ /**
428
+ * Validate user password for AES-256 (R=5: Adobe extension / R=6: Algorithm 11)
429
+ *
430
+ * Steps:
431
+ * 1. hash(password, U[32:40], []) → compare with U[0:32]
432
+ * 2. If match: hash(password, U[40:48], []) → decrypt UE → file key
433
+ *
434
+ * @returns {Promise<Uint8Array|null>} - 32-byte file key if valid, null if invalid
435
+ */
436
+ async function validateUserPasswordAES256(password, encryptParams) {
437
+ const { userKey, userEncryptKey, revision } = encryptParams;
438
+
439
+ // U validation salt = U[32:40]
440
+ const validationSalt = userKey.slice(32, 40);
441
+ const hash = await aes256PasswordHash(password, validationSalt, new Uint8Array(0), revision);
442
+
443
+ // Compare hash with U[0:32]
444
+ if (!arraysEqual(hash, userKey.slice(0, 32))) {
445
+ return null;
446
+ }
447
+
448
+ // Password valid — derive file key from UE
449
+ // Key salt = U[40:48]
450
+ const keySalt = userKey.slice(40, 48);
451
+ const ueKey = await aes256PasswordHash(password, keySalt, new Uint8Array(0), revision);
452
+ const zeroIV = new Uint8Array(16);
453
+ const fileKey = await aes256CbcDecryptNoPad(userEncryptKey, ueKey, zeroIV);
454
+
455
+ return fileKey;
456
+ }
457
+
458
+ /**
459
+ * Validate owner password for AES-256 (R=5: Adobe extension / R=6: Algorithm 12)
460
+ *
461
+ * Steps:
462
+ * 1. hash(password, O[32:40], U) → compare with O[0:32]
463
+ * 2. If match: hash(password, O[40:48], U) → decrypt OE → file key
464
+ *
465
+ * @returns {Promise<Uint8Array|null>} - 32-byte file key if valid, null if invalid
466
+ */
467
+ async function validateOwnerPasswordAES256(password, encryptParams) {
468
+ const { ownerKey, userKey, ownerEncryptKey, revision } = encryptParams;
469
+
470
+ // O validation salt = O[32:40], userKey = full 48-byte U value
471
+ const validationSalt = ownerKey.slice(32, 40);
472
+ const hash = await aes256PasswordHash(password, validationSalt, userKey, revision);
473
+
474
+ // Compare hash with O[0:32]
475
+ if (!arraysEqual(hash, ownerKey.slice(0, 32))) {
476
+ return null;
477
+ }
478
+
479
+ // Password valid — derive file key from OE
480
+ // Key salt = O[40:48]
481
+ const keySalt = ownerKey.slice(40, 48);
482
+ const oeKey = await aes256PasswordHash(password, keySalt, userKey, revision);
483
+ const zeroIV = new Uint8Array(16);
484
+ const fileKey = await aes256CbcDecryptNoPad(ownerEncryptKey, oeKey, zeroIV);
485
+
486
+ return fileKey;
487
+ }
488
+
489
+ /**
490
+ * Verify Perms value (Algorithm 13 / ISO 32000-2)
491
+ * Decrypts /Perms with the file key and checks consistency
492
+ *
493
+ * @returns {boolean} - true if Perms is valid
494
+ */
495
+ async function verifyPerms(fileKey, encryptParams) {
496
+ const { perms, permissions, encryptMetadata } = encryptParams;
497
+
498
+ try {
499
+ const decrypted = await aes256EcbDecryptBlock(perms, fileKey);
500
+
501
+ // Bytes 0-3 should match permissions (little-endian)
502
+ const p0 = decrypted[0] | (decrypted[1] << 8) | (decrypted[2] << 16) | (decrypted[3] << 24);
503
+ if ((p0 | 0) !== (permissions | 0)) {
504
+ return false;
505
+ }
506
+
507
+ // Byte 8 should be 'T' (0x54) or 'F' (0x46)
508
+ const expectedEM = encryptMetadata ? 0x54 : 0x46;
509
+ if (decrypted[8] !== expectedEM) {
510
+ return false;
511
+ }
512
+
513
+ // Bytes 9-11 should be 'a', 'd', 'b'
514
+ if (decrypted[9] !== 0x61 || decrypted[10] !== 0x64 || decrypted[11] !== 0x62) {
515
+ return false;
516
+ }
517
+
518
+ return true;
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+
524
+ // ========== AES-256 Object Decryption (Batched) ==========
525
+
526
+ /**
527
+ * Collect all encrypted items from the PDF object tree (synchronous traversal)
528
+ * Returns arrays of items that need AES-256 decryption
529
+ */
530
+ function collectEncryptedItems(context, encryptRefNum, encryptMetadata) {
531
+ const streamItems = [];
532
+ const stringItems = [];
533
+ const indirectObjects = context.enumerateIndirectObjects();
534
+
535
+ for (const [ref, obj] of indirectObjects) {
536
+ const objectNum = ref.objectNumber;
537
+ const generationNum = ref.generationNumber || 0;
538
+
539
+ // Skip the encryption dictionary itself
540
+ if (encryptRefNum !== null && objectNum === encryptRefNum) {
541
+ continue;
542
+ }
543
+
544
+ // Skip signature dictionaries — /Contents and /ByteRange must not be decrypted
545
+ // per PDF spec Section 7.6.1 (applies to both stream and non-stream /Sig objects)
546
+ if (obj instanceof PDFDict && !(obj instanceof PDFRawStream)) {
547
+ const type = obj.get(PDFName.of('Type'));
548
+ if (type && type.toString() === '/Sig') continue;
549
+ }
550
+
551
+ // Check if this is a stream with a /Type we should skip
552
+ if (obj instanceof PDFRawStream && obj.dict) {
553
+ const type = obj.dict.get(PDFName.of('Type'));
554
+ if (type) {
555
+ const typeName = type.toString();
556
+ // XRef streams are never encrypted
557
+ if (typeName === '/XRef') continue;
558
+ // Signature streams must not be decrypted
559
+ if (typeName === '/Sig') continue;
560
+ // Skip metadata streams when EncryptMetadata is false
561
+ if (typeName === '/Metadata' && !encryptMetadata) continue;
562
+ }
563
+ }
564
+
565
+ // Collect streams for decryption
566
+ if (obj instanceof PDFRawStream) {
567
+ const streamData = obj.contents;
568
+ // AES-256 streams: IV (16 bytes) + ciphertext
569
+ if (streamData.length >= 16) {
570
+ streamItems.push({ ref, obj, data: streamData, objectNum, generationNum });
571
+ }
572
+
573
+ // Collect strings in stream dictionaries
574
+ if (obj.dict) {
575
+ collectStringsFromObject(obj.dict, objectNum, generationNum, stringItems);
576
+ }
577
+ }
578
+
579
+ // Collect strings in non-stream objects
580
+ if (!(obj instanceof PDFRawStream)) {
581
+ collectStringsFromObject(obj, objectNum, generationNum, stringItems);
582
+ }
583
+ }
584
+
585
+ return { streamItems, stringItems };
586
+ }
587
+
588
+ /**
589
+ * Recursively collect encrypted strings from a PDF object (synchronous)
590
+ */
591
+ function collectStringsFromObject(obj, objectNum, generationNum, items) {
592
+ if (!obj) return;
593
+
594
+ if (obj instanceof PDFString) {
595
+ const bytes = obj.asBytes();
596
+ if (bytes.length >= 16) {
597
+ items.push({ obj, bytes, type: 'string', objectNum, generationNum });
598
+ }
599
+ } else if (obj instanceof PDFHexString) {
600
+ const bytes = obj.asBytes();
601
+ if (bytes.length >= 16) {
602
+ items.push({ obj, bytes, type: 'hex', objectNum, generationNum });
603
+ }
604
+ } else if (obj instanceof PDFDict) {
605
+ for (const [key, value] of obj.entries()) {
606
+ const keyName = key.asString();
607
+ if (keyName !== '/Length' && keyName !== '/Filter' && keyName !== '/DecodeParms') {
608
+ collectStringsFromObject(value, objectNum, generationNum, items);
609
+ }
610
+ }
611
+ } else if (obj instanceof PDFArray) {
612
+ for (const element of obj.asArray()) {
613
+ collectStringsFromObject(element, objectNum, generationNum, items);
614
+ }
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Decrypt a single AES-256 encrypted blob (IV prepended)
620
+ * Per PDF 2.0: first 16 bytes are the IV, rest is ciphertext
621
+ */
622
+ async function decryptAES256Blob(data, cryptoKey) {
623
+ const iv = data.slice(0, 16);
624
+ const ciphertext = data.slice(16);
625
+
626
+ // Edge case: empty ciphertext after IV
627
+ if (ciphertext.length === 0) {
628
+ return new Uint8Array(0);
629
+ }
630
+
631
+ // Ciphertext must be a multiple of 16 bytes for AES-CBC
632
+ if (ciphertext.length % 16 !== 0) {
633
+ return data; // Return original data if not valid AES-CBC
634
+ }
635
+
636
+ try {
637
+ return await aes256CbcDecryptWithKey(ciphertext, cryptoKey, iv);
638
+ } catch {
639
+ // If decryption fails (e.g., invalid padding), return original
640
+ return data;
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Decrypt all collected items in batches using Promise.all
646
+ * This avoids the microtask queue overhead of awaiting each item individually
647
+ */
648
+ async function decryptAllAES256(streamItems, stringItems, cryptoKey) {
649
+ // Decrypt streams in batches
650
+ for (let i = 0; i < streamItems.length; i += BATCH_SIZE) {
651
+ const batch = streamItems.slice(i, i + BATCH_SIZE);
652
+ const results = await Promise.all(
653
+ batch.map(item => decryptAES256Blob(item.data, cryptoKey))
654
+ );
655
+
656
+ // Write results back synchronously
657
+ for (let j = 0; j < batch.length; j++) {
658
+ batch[j].obj.contents = results[j];
659
+ }
660
+ }
661
+
662
+ // Decrypt strings in batches
663
+ for (let i = 0; i < stringItems.length; i += BATCH_SIZE) {
664
+ const batch = stringItems.slice(i, i + BATCH_SIZE);
665
+ const results = await Promise.all(
666
+ batch.map(item => decryptAES256Blob(item.bytes, cryptoKey))
667
+ );
668
+
669
+ // Write results back synchronously
670
+ for (let j = 0; j < batch.length; j++) {
671
+ const item = batch[j];
672
+ const decrypted = results[j];
673
+
674
+ if (item.type === 'string') {
675
+ item.obj.value = Array.from(decrypted).map(b => String.fromCharCode(b)).join('');
676
+ } else {
677
+ item.obj.value = bytesToHex(decrypted);
678
+ }
679
+ }
680
+ }
681
+ }
682
+
683
+ // ========== RC4 Decryption Path ==========
684
+
685
+ /**
686
+ * Decrypt all objects using RC4 (synchronous, from decrypt-lite)
687
+ */
688
+ function decryptAllRC4(context, encryptionKey, encryptRefNum) {
689
+ const indirectObjects = context.enumerateIndirectObjects();
690
+
691
+ for (const [ref, obj] of indirectObjects) {
692
+ const objectNum = ref.objectNumber;
693
+ const generationNum = ref.generationNumber || 0;
694
+
695
+ // Skip the encryption dictionary itself
696
+ if (encryptRefNum !== null && objectNum === encryptRefNum) {
697
+ continue;
698
+ }
699
+
700
+ // Skip objects that must not be decrypted per PDF spec (Section 7.6.1)
701
+ // Signature dictionaries: /Contents and /ByteRange must not be decrypted
702
+ if (obj instanceof PDFDict && !(obj instanceof PDFRawStream)) {
703
+ const type = obj.get(PDFName.of('Type'));
704
+ if (type && type.toString() === '/Sig') continue;
705
+ }
706
+
707
+ if (obj instanceof PDFRawStream && obj.dict) {
708
+ const type = obj.dict.get(PDFName.of('Type'));
709
+ if (type) {
710
+ const typeName = type.toString();
711
+ if (typeName === '/XRef' || typeName === '/Sig') {
712
+ continue;
713
+ }
714
+ }
715
+ }
716
+
717
+ // Decrypt streams
718
+ if (obj instanceof PDFRawStream) {
719
+ const streamData = obj.contents;
720
+ const decrypted = decryptObjectRC4(streamData, objectNum, generationNum, encryptionKey);
721
+ obj.contents = decrypted;
722
+
723
+ // Also decrypt strings within the stream's dictionary
724
+ if (obj.dict) {
725
+ decryptStringsRC4(obj.dict, objectNum, generationNum, encryptionKey);
726
+ }
727
+ }
728
+
729
+ // Decrypt strings in non-stream objects
730
+ if (!(obj instanceof PDFRawStream)) {
731
+ decryptStringsRC4(obj, objectNum, generationNum, encryptionKey);
732
+ }
733
+ }
734
+ }
735
+
736
+ // ========== Main API ==========
737
+
738
+ /**
739
+ * Decrypt a password-protected PDF
740
+ *
741
+ * Supports both AES-256 (V=5, R=6) and RC4 (V=1-2, R=2-3) encryption.
742
+ * React Native compatible PDF decryption (fork of @pdfsmaller/pdf-decrypt)
743
+ * https://github.com/imdewan/rn-pdf-decrypt
744
+ *
745
+ * @param {Uint8Array} pdfBytes - The encrypted PDF file as bytes
746
+ * @param {string} password - The user or owner password
747
+ * @returns {Promise<Uint8Array>} - The decrypted PDF bytes
748
+ * @throws {Error} If the PDF is not encrypted, password is wrong, or encryption is unsupported
749
+ *
750
+ * @example
751
+ * import { decryptPDF } from 'rn-pdf-decrypt';
752
+ *
753
+ * const decrypted = await decryptPDF(encryptedBytes, 'secret123');
754
+ */
755
+ export async function decryptPDF(pdfBytes, password) {
756
+ try {
757
+ // Load the PDF without attempting to decrypt (let us handle it)
758
+ const pdfDoc = await PDFDocument.load(pdfBytes, {
759
+ ignoreEncryption: true,
760
+ updateMetadata: false
761
+ });
762
+
763
+ const context = pdfDoc.context;
764
+
765
+ // Read encryption parameters
766
+ const encryptParams = readEncryptParams(context);
767
+
768
+ if (!encryptParams) {
769
+ throw new Error('This PDF is not encrypted. No /Encrypt dictionary found.');
770
+ }
771
+
772
+ const encryptRefNum = (encryptParams.encryptRef instanceof PDFRef)
773
+ ? encryptParams.encryptRef.objectNumber
774
+ : null;
775
+
776
+ if (encryptParams.algorithm === 'AES-256') {
777
+ // ========== AES-256 Path ==========
778
+ const pwdBytes = saslPrepPassword(password);
779
+
780
+ // Try user password first
781
+ let fileKey = await validateUserPasswordAES256(pwdBytes, encryptParams);
782
+
783
+ // If user password fails, try owner password
784
+ if (!fileKey) {
785
+ fileKey = await validateOwnerPasswordAES256(pwdBytes, encryptParams);
786
+ }
787
+
788
+ if (!fileKey) {
789
+ throw new Error('Incorrect password. The provided password does not match the user or owner password.');
790
+ }
791
+
792
+ // Verify Perms (optional but recommended — warns but doesn't fail)
793
+ const permsValid = await verifyPerms(fileKey, encryptParams);
794
+ if (!permsValid) {
795
+ // Some PDFs have invalid Perms but are otherwise fine — proceed anyway
796
+ }
797
+
798
+ // Import key once for bulk decryption
799
+ const cryptoKey = await importAES256DecryptKey(fileKey);
800
+
801
+ // Collect all encrypted items (synchronous traversal)
802
+ const { streamItems, stringItems } = collectEncryptedItems(
803
+ context, encryptRefNum, encryptParams.encryptMetadata
804
+ );
805
+
806
+ // Decrypt all items in batches (async)
807
+ await decryptAllAES256(streamItems, stringItems, cryptoKey);
808
+
809
+ } else {
810
+ // ========== RC4 Path ==========
811
+ // Try user password first
812
+ let encryptionKey = validateUserPasswordRC4(password, encryptParams);
813
+
814
+ if (!encryptionKey) {
815
+ encryptionKey = validateOwnerPasswordRC4(password, encryptParams);
816
+ }
817
+
818
+ if (!encryptionKey) {
819
+ throw new Error('Incorrect password. The provided password does not match the user or owner password.');
820
+ }
821
+
822
+ // Decrypt all objects (synchronous)
823
+ decryptAllRC4(context, encryptionKey, encryptRefNum);
824
+ }
825
+
826
+ // Remove the /Encrypt entry from the trailer
827
+ delete context.trailerInfo.Encrypt;
828
+
829
+ // Save the decrypted PDF
830
+ const decryptedBytes = await pdfDoc.save({
831
+ useObjectStreams: false
832
+ });
833
+
834
+ return decryptedBytes;
835
+
836
+ } catch (error) {
837
+ if (error.message.includes('not encrypted') ||
838
+ error.message.includes('Incorrect password') ||
839
+ error.message.includes('Unsupported encryption')) {
840
+ throw error;
841
+ }
842
+ throw new Error(`Failed to decrypt PDF: ${error.message}`);
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Check if a PDF is encrypted (without attempting to decrypt)
848
+ *
849
+ * @param {Uint8Array} pdfBytes - The PDF file as bytes
850
+ * @returns {Promise<{encrypted: boolean, algorithm?: 'AES-256'|'RC4', version?: number, revision?: number, keyLength?: number}>}
851
+ *
852
+ * @example
853
+ * const info = await isEncrypted(pdfBytes);
854
+ * if (info.encrypted) {
855
+ * console.log(`Encrypted with ${info.algorithm}`);
856
+ * }
857
+ */
858
+ export async function isEncrypted(pdfBytes) {
859
+ try {
860
+ const pdfDoc = await PDFDocument.load(pdfBytes, {
861
+ ignoreEncryption: true,
862
+ updateMetadata: false
863
+ });
864
+
865
+ const encryptParams = readEncryptParams(pdfDoc.context);
866
+
867
+ if (!encryptParams) {
868
+ return { encrypted: false };
869
+ }
870
+
871
+ return {
872
+ encrypted: true,
873
+ algorithm: encryptParams.algorithm,
874
+ version: encryptParams.version,
875
+ revision: encryptParams.revision,
876
+ keyLength: encryptParams.keyLength * 8 // return in bits
877
+ };
878
+ } catch (error) {
879
+ throw new Error(`Failed to read PDF: ${error.message}`);
880
+ }
881
+ }
882
+
883
+ /**
884
+ * rn-pdf-decrypt — https://github.com/imdewan/rn-pdf-decrypt
885
+ * Fork of @pdfsmaller/pdf-decrypt for React Native compatibility
886
+ */