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