Skip to content

Commit 45cdb5d

Browse files
committed
Add support for encrypted attachments
This PR is related to GH-20732, which is about `AuthEvent` (to delay promting for a password), but instead adds the actual support for encrypted attachments. “Encrypted attachments” means that the main things are plain text. Note that some PDF viewers, like Preview/QuickLook/Safari or Chrome, do not support attachments at all. Note that the file checked into the tests is the same as `output-no-auth-event.pdf` referenced in <#20139 (comment)>. Closes GH-20139.
1 parent b849567 commit 45cdb5d

5 files changed

Lines changed: 245 additions & 81 deletions

File tree

src/core/crypto.js

Lines changed: 113 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ import { calculateMD5 } from "./calculate_md5.js";
3131
import { calculateSHA256 } from "./calculate_sha256.js";
3232
import { DecryptStream } from "./decrypt_stream.js";
3333

34+
/**
35+
* @typedef {typeof AES128Cipher | typeof AES256Cipher | typeof ARCFourCipher
36+
* | typeof NullCipher} CipherConstructors
37+
*/
38+
39+
/**
40+
* @callback ResolveCipher
41+
* Find the appropriate cipher class based on the filter name.
42+
* @param {Name | null} [filterName]
43+
* Name.
44+
* @returns {CipherConstructors}
45+
* Cipher constructor.
46+
*/
47+
3448
class ARCFourCipher {
3549
a = 0;
3650

@@ -737,13 +751,46 @@ class PDF20 extends PDFBase {
737751
}
738752

739753
class CipherTransform {
740-
constructor(stringCipherConstructor, streamCipherConstructor) {
741-
this.StringCipherConstructor = stringCipherConstructor;
742-
this.StreamCipherConstructor = streamCipherConstructor;
754+
/** @type {Map<string, CipherConstructors>} */
755+
#cipherCache = new Map();
756+
757+
/**
758+
* @param {ResolveCipher} resolveCipher
759+
* Resolve a cipher constructor from a crypt filter name.
760+
* @param {Name | null} [stringFilterName]
761+
* Default crypt filter for strings.
762+
* @param {Name | null} [streamFilterName]
763+
* Default crypt filter for streams.
764+
*/
765+
constructor(resolveCipher, stringFilterName = null, streamFilterName = null) {
766+
this.resolveCipher = resolveCipher;
767+
this.streamFilterName = streamFilterName;
768+
this.stringFilterName = stringFilterName;
743769
}
744770

745-
createStream(stream, length) {
746-
const cipher = new this.StreamCipherConstructor();
771+
/**
772+
* @param {Name | null} [filterName]
773+
* Crypt filter name.
774+
* @returns {CipherConstructors}
775+
* Cipher constructor.
776+
*/
777+
#getCipher(filterName = null) {
778+
const key = filterName instanceof Name ? filterName.name : "__default__";
779+
780+
return this.#cipherCache.getOrInsertComputed(key, () =>
781+
this.resolveCipher(filterName)
782+
);
783+
}
784+
785+
/**
786+
* @param {BaseStream} stream
787+
* @param {number | null} length
788+
* @param {Name | null} [cryptFilterName]
789+
* @returns {DecryptStream}
790+
*/
791+
createStream(stream, length, cryptFilterName = null) {
792+
const Cipher = this.#getCipher(cryptFilterName || this.streamFilterName);
793+
const cipher = new Cipher();
747794
return new DecryptStream(
748795
stream,
749796
length,
@@ -754,14 +801,16 @@ class CipherTransform {
754801
}
755802

756803
decryptString(s) {
757-
const cipher = new this.StringCipherConstructor();
804+
const Cipher = this.#getCipher(this.stringFilterName);
805+
const cipher = new Cipher();
758806
let data = stringToBytes(s);
759807
data = cipher.decryptBlock(data, true);
760808
return bytesToString(data);
761809
}
762810

763811
encryptString(s) {
764-
const cipher = new this.StringCipherConstructor();
812+
const Cipher = this.#getCipher(this.stringFilterName);
813+
const cipher = new Cipher();
765814
if (cipher instanceof AESBaseCipher) {
766815
// Append some chars equal to "16 - (M mod 16)"
767816
// where M is the string length (see section 7.6.2 in PDF specification)
@@ -986,41 +1035,6 @@ class CipherTransformFactory {
9861035
return hash.subarray(0, Math.min(n + 5, 16));
9871036
}
9881037

989-
#buildCipherConstructor(cf, name, num, gen, key) {
990-
if (!(name instanceof Name)) {
991-
throw new FormatError("Invalid crypt filter name.");
992-
}
993-
const self = this;
994-
const cryptFilter = cf.get(name.name);
995-
const cfm = cryptFilter?.get("CFM");
996-
997-
if (!cfm || cfm.name === "None") {
998-
return function () {
999-
return new NullCipher();
1000-
};
1001-
}
1002-
if (cfm.name === "V2") {
1003-
return function () {
1004-
return new ARCFourCipher(
1005-
self.#buildObjectKey(num, gen, key, /* isAes = */ false)
1006-
);
1007-
};
1008-
}
1009-
if (cfm.name === "AESV2") {
1010-
return function () {
1011-
return new AES128Cipher(
1012-
self.#buildObjectKey(num, gen, key, /* isAes = */ true)
1013-
);
1014-
};
1015-
}
1016-
if (cfm.name === "AESV3") {
1017-
return function () {
1018-
return new AES256Cipher(key);
1019-
};
1020-
}
1021-
throw new FormatError("Unknown crypto method");
1022-
}
1023-
10241038
constructor(dict, fileId, password) {
10251039
const filter = dict.get("Filter");
10261040
if (!isName(filter, "Standard")) {
@@ -1185,43 +1199,74 @@ class CipherTransformFactory {
11851199
}
11861200
}
11871201

1202+
/**
1203+
* @param {number} num
1204+
* Object number.
1205+
* @param {number} gen
1206+
* Generation number.
1207+
* @returns {CipherTransform}
1208+
* Cipher transform.
1209+
*/
11881210
createCipherTransform(num, gen) {
11891211
if (this.algorithm === 4 || this.algorithm === 5) {
1190-
return new CipherTransform(
1191-
this.#buildCipherConstructor(
1192-
this.cf,
1193-
this.strf,
1194-
num,
1195-
gen,
1196-
this.encryptionKey
1197-
),
1198-
this.#buildCipherConstructor(
1199-
this.cf,
1200-
this.stmf,
1201-
num,
1202-
gen,
1203-
this.encryptionKey
1204-
)
1205-
);
1212+
/** @type {ResolveCipher} */
1213+
const resolveCipher = filterName => {
1214+
if (!(filterName instanceof Name)) {
1215+
throw new FormatError("Invalid crypt filter name.");
1216+
}
1217+
const cryptFilter = this.cf.get(filterName.name);
1218+
const cfm = cryptFilter?.get("CFM");
1219+
1220+
if (!cfm || cfm.name === "None") {
1221+
return NullCipher;
1222+
}
1223+
if (cfm.name === "V2") {
1224+
return ARCFourCipher.bind(
1225+
null,
1226+
this.#buildObjectKey(
1227+
num,
1228+
gen,
1229+
this.encryptionKey,
1230+
/* isAes = */ false
1231+
)
1232+
);
1233+
}
1234+
if (cfm.name === "AESV2") {
1235+
return AES128Cipher.bind(
1236+
null,
1237+
this.#buildObjectKey(
1238+
num,
1239+
gen,
1240+
this.encryptionKey,
1241+
/* isAes = */ true
1242+
)
1243+
);
1244+
}
1245+
if (cfm.name === "AESV3") {
1246+
return AES256Cipher.bind(null, this.encryptionKey);
1247+
}
1248+
throw new FormatError("Unknown crypto method");
1249+
};
1250+
1251+
return new CipherTransform(resolveCipher, this.strf, this.stmf);
12061252
}
1253+
12071254
// algorithms 1 and 2
1208-
const key = this.#buildObjectKey(
1209-
num,
1210-
gen,
1211-
this.encryptionKey,
1212-
/* isAes = */ false
1213-
);
1214-
const cipherConstructor = function () {
1215-
return new ARCFourCipher(key);
1216-
};
1217-
return new CipherTransform(cipherConstructor, cipherConstructor);
1255+
/** @type {ResolveCipher} */
1256+
const resolveCipher = () =>
1257+
ARCFourCipher.bind(
1258+
null,
1259+
this.#buildObjectKey(num, gen, this.encryptionKey, /* isAes = */ false)
1260+
);
1261+
return new CipherTransform(resolveCipher);
12181262
}
12191263
}
12201264

12211265
export {
12221266
AES128Cipher,
12231267
AES256Cipher,
12241268
ARCFourCipher,
1269+
CipherTransform,
12251270
CipherTransformFactory,
12261271
PDF17,
12271272
PDF20,

0 commit comments

Comments
 (0)