From f5b6ddd2a9d52333ef0f99dca37d88b718125069 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Thu, 6 Dec 2018 23:33:09 +0900 Subject: [PATCH] Add PDF security features with ES6 --- lib/document.js | 54 +++++-- lib/object.js | 43 +++-- lib/reference.js | 18 ++- lib/security.js | 399 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +- 5 files changed, 493 insertions(+), 27 deletions(-) create mode 100644 lib/security.js diff --git a/lib/document.js b/lib/document.js index e4d5986..43d670d 100644 --- a/lib/document.js +++ b/lib/document.js @@ -8,6 +8,7 @@ import fs from 'fs'; import PDFObject from './object'; import PDFReference from './reference'; import PDFPage from './page'; +import PDFSecurity from './security'; import ColorMixin from './mixins/color'; import VectorMixin from './mixins/vector'; import FontsMixin from './mixins/fonts'; @@ -22,7 +23,24 @@ class PDFDocument extends stream.Readable { this.options = options; // PDF version - this.version = 1.3; + switch (options.pdfVersion) { + case '1.4': + this.version = 1.4; + break; + case '1.5': + this.version = 1.5; + break; + case '1.6': + this.version = 1.6; + break; + case '1.7': + case '1.7ext3': + this.version = 1.7; + break; + default: + this.version = 1.3; + break; + } // Whether streams should be compressed this.compress = this.options.compress != null ? this.options.compress : true; @@ -82,6 +100,12 @@ class PDFDocument extends stream.Readable { } } + // Generate file ID + this._id = PDFSecurity.generateFileID(this.info); + + // Initialize security settings + this._security = PDFSecurity.create(this, options); + // Write the header // PDF version this._write(`%PDF-${this.version}`); @@ -213,7 +237,10 @@ Please pipe the document into a Node stream.\ val = new String(val); } - this._info.data[key] = val; + let entry = this.ref(val); + entry.end(); + + this._info.data[key] = entry; } this._info.end(); @@ -224,10 +251,14 @@ Please pipe the document into a Node stream.\ } this.endOutline(); - + this._root.end(); this._root.data.Pages.end(); + if (this._security) { + this._security.end(); + } + if (this._waiting === 0) { return this._finalize(); } else { @@ -248,13 +279,18 @@ Please pipe the document into a Node stream.\ } // trailer - this._write('trailer'); - this._write(PDFObject.convert({ + const trailer = { Size: this._offsets.length + 1, Root: this._root, - Info: this._info - }) - ); + Info: this._info, + ID: [this._id, this._id] + }; + if (this._security) { + trailer.Encrypt = this._security.dictionary; + } + + this._write('trailer'); + this._write(PDFObject.convert(trailer)); this._write('startxref'); this._write(`${xRefOffset}`); @@ -270,7 +306,7 @@ Please pipe the document into a Node stream.\ }; const mixin = methods => { - Object.assign(PDFDocument.prototype, methods); + Object.assign(PDFDocument.prototype, methods); }; mixin(ColorMixin); diff --git a/lib/object.js b/lib/object.js index f878160..1491f11 100644 --- a/lib/object.js +++ b/lib/object.js @@ -6,7 +6,7 @@ By Devon Govett import PDFAbstractReference from './abstract_reference'; const pad = (str, length) => (Array(length + 1).join('0') + str).slice(-length); - + const escapableRe = /[\n\r\t\b\f\(\)\\]/g; const escapable = { '\n': '\\n', @@ -36,7 +36,7 @@ const swapBytes = function(buff) { }; class PDFObject { - static convert(object) { + static convert(object, encryptFn = null) { // String literals are converted to the PDF name type if (typeof object === 'string') { return `/${object}`; @@ -54,8 +54,18 @@ class PDFObject { } // If so, encode it as big endian UTF-16 + let stringBuffer; if (isUnicode) { - string = swapBytes(new Buffer(`\ufeff${string}`, 'utf16le')).toString('binary'); + stringBuffer = swapBytes(new Buffer(`\ufeff${string}`, 'utf16le')); + } else { + stringBuffer = new Buffer(string, 'ascii'); + } + + // Encrypt the string when necessary + if (encryptFn) { + string = encryptFn(stringBuffer).toString('binary'); + } else { + string = stringBuffer.toString('binary'); } // Escape characters as required by the spec @@ -71,23 +81,32 @@ class PDFObject { return object.toString(); } else if (object instanceof Date) { - return `(D:${pad(object.getUTCFullYear(), 4)}` + - pad(object.getUTCMonth() + 1, 2) + - pad(object.getUTCDate(), 2) + - pad(object.getUTCHours(), 2) + - pad(object.getUTCMinutes(), 2) + - pad(object.getUTCSeconds(), 2) + - 'Z)'; + let string = `D:${pad(object.getUTCFullYear(), 4)}` + + pad(object.getUTCMonth() + 1, 2) + + pad(object.getUTCDate(), 2) + + pad(object.getUTCHours(), 2) + + pad(object.getUTCMinutes(), 2) + + pad(object.getUTCSeconds(), 2) + 'Z'; + + // Encrypt the string when necessary + if (encryptFn) { + string = encryptFn(new Buffer(string, 'ascii')).toString('binary'); + + // Escape characters as required by the spec + string = string.replace(escapableRe, c => escapable[c]); + } + + return `(${string})`; } else if (Array.isArray(object)) { - const items = (object.map((e) => PDFObject.convert(e))).join(' '); + const items = (object.map((e) => PDFObject.convert(e, encryptFn))).join(' '); return `[${items}]`; } else if ({}.toString.call(object) === '[object Object]') { const out = ['<<']; for (let key in object) { const val = object[key]; - out.push(`/${key} ${PDFObject.convert(val)}`); + out.push(`/${key} ${PDFObject.convert(val, encryptFn)}`); } out.push('>>'); diff --git a/lib/reference.js b/lib/reference.js index 3958ce1..067fcb3 100644 --- a/lib/reference.js +++ b/lib/reference.js @@ -9,7 +9,7 @@ import PDFObject from './object'; class PDFReference extends PDFAbstractReference { constructor(document, id, data) { - super(); + super(); this.document = document; this.id = id; if (data == null) { data = {}; } @@ -45,15 +45,25 @@ class PDFReference extends PDFAbstractReference { return setTimeout(() => { this.offset = this.document._offset; - this.document._write(`${this.id} ${this.gen} obj`); - this.document._write(PDFObject.convert(this.data)); + const encryptFn = this.document._security ? this.document._security.getEncryptFn(this.id, this.gen) : null; if (this.buffer.length) { this.buffer = Buffer.concat(this.buffer); if (this.compress) { this.buffer = zlib.deflateSync(this.buffer); - this.data.Length = this.buffer.length; } + + if (encryptFn) { + this.buffer = encryptFn(this.buffer); + } + + this.data.Length = this.buffer.length; + } + + this.document._write(`${this.id} ${this.gen} obj`); + this.document._write(PDFObject.convert(this.data, encryptFn)); + + if (this.buffer.length) { this.document._write('stream'); this.document._write(this.buffer); diff --git a/lib/security.js b/lib/security.js new file mode 100644 index 0000000..a634e5d --- /dev/null +++ b/lib/security.js @@ -0,0 +1,399 @@ +/* + PDFSecurity - represents PDF security settings + By Yang Liu + */ + +import CryptoJS from 'crypto-js'; +import saslprep from 'saslprep'; + +class PDFSecurity { + static generateFileID(info = {}) { + let infoStr = `${new Date().getTime()}\n`; + + for (let key in info) { + if (!info.hasOwnProperty(key)) { + continue; + } + infoStr += `${key}: ${info[key].toString()}\n`; + } + + return wordArrayToBuffer(CryptoJS.MD5(infoStr)); + } + + static create(document, options = {}) { + if (!options.ownerPassword && !options.userPassword) { + return null; + } + return new PDFSecurity(document, options); + } + + constructor(document, options = {}) { + if (!options.ownerPassword && !options.userPassword) { + throw new Error('None of owner password and user password is defined.'); + } + + this.document = document; + this._setupEncryption(options); + } + + _setupEncryption(options) { + switch (options.pdfVersion) { + case '1.4': + case '1.5': + this.version = 2; + break; + case '1.6': + case '1.7': + this.version = 4; + break; + case '1.7ext3': + this.version = 5; + break; + default: + this.version = 1; + break; + } + + const encDict = { + Filter: 'Standard' + }; + + switch (this.version) { + case 1: + case 2: + case 4: + this._setupEncryptionV1V2V4(this.version, encDict, options); + break; + case 5: + this._setupEncryptionV5(encDict, options); + break; + } + + this.dictionary = this.document.ref(encDict); + } + + _setupEncryptionV1V2V4(v, encDict, options) { + let r, permissions; + switch (v) { + case 1: + r = 2; + this.keyBits = 40; + permissions = getPermissionsR2(options); + break; + case 2: + r = 3; + this.keyBits = 128; + permissions = getPermissionsR3(options); + break; + case 4: + r = 4; + this.keyBits = 128; + permissions = getPermissionsR3(options); + break; + } + + const paddedUserPassword = processPasswordR2R3R4(options.userPassword); + const paddedOwnerPassword = options.ownerPassword ? + processPasswordR2R3R4(options.ownerPassword) : paddedUserPassword; + + const ownerPasswordEntry = getOwnerPasswordR2R3R4(r, this.keyBits, paddedUserPassword, paddedOwnerPassword); + this.encryptionKey = getEncryptionKeyR2R3R4(r, this.keyBits, this.document._id, + paddedUserPassword, ownerPasswordEntry, permissions); + let userPasswordEntry; + if (r === 2) { + userPasswordEntry = getUserPasswordR2(this.encryptionKey); + } else { + userPasswordEntry = getUserPasswordR3R4(this.document._id, this.encryptionKey); + } + + encDict.V = v; + if (v >= 2) { + encDict.Length = this.keyBits; + } + if (v === 4) { + encDict.CF = { + StdCF: { + AuthEvent: 'DocOpen', + CFM: 'AESV2', + Length: this.keyBits / 8 + } + }; + encDict.StmF = 'StdCF'; + encDict.StrF = 'StdCF'; + } + encDict.R = r; + encDict.O = wordArrayToBuffer(ownerPasswordEntry); + encDict.U = wordArrayToBuffer(userPasswordEntry); + encDict.P = permissions; + } + + _setupEncryptionV5(encDict, options) { + this.keyBits = 256; + const permissions = getPermissionsR3(options); + + const processedUserPassword = processPasswordR5(options.userPassword); + const processedOwnerPassword = options.ownerPassword ? + processPasswordR5(options.ownerPassword) : processedUserPassword; + + this.encryptionKey = getEncryptionKeyR5(); + const userPasswordEntry = getUserPasswordR5(processedUserPassword); + const userKeySalt = CryptoJS.lib.WordArray.create(userPasswordEntry.words.slice(10, 12), 8); + const userEncryptionKeyEntry = getUserEncryptionKeyR5(processedUserPassword, userKeySalt, this.encryptionKey); + const ownerPasswordEntry = getOwnerPasswordR5(processedOwnerPassword, userPasswordEntry); + const ownerKeySalt = CryptoJS.lib.WordArray.create(ownerPasswordEntry.words.slice(10, 12), 8); + const ownerEncryptionKeyEntry = getOwnerEncryptionKeyR5(processedOwnerPassword, ownerKeySalt, userPasswordEntry, + this.encryptionKey); + const permsEntry = getEncryptedPermissionsR5(permissions, this.encryptionKey); + + encDict.V = 5; + encDict.Length = this.keyBits; + encDict.CF = { + StdCF: { + AuthEvent: 'DocOpen', + CFM: 'AESV3', + Length: this.keyBits / 8 + } + }; + encDict.StmF = 'StdCF'; + encDict.StrF = 'StdCF'; + encDict.R = 5; + encDict.O = wordArrayToBuffer(ownerPasswordEntry); + encDict.OE = wordArrayToBuffer(ownerEncryptionKeyEntry); + encDict.U = wordArrayToBuffer(userPasswordEntry); + encDict.UE = wordArrayToBuffer(userEncryptionKeyEntry); + encDict.P = permissions; + encDict.Perms = wordArrayToBuffer(permsEntry); + } + + getEncryptFn(obj, gen) { + let digest; + if (this.version < 5) { + digest = this.encryptionKey.clone().concat(CryptoJS.lib.WordArray.create([ + ((obj & 0xff) << 24) | ((obj & 0xff00) << 8) | ((obj >> 8) & 0xff00) | (gen & 0xff), (gen & 0xff00) << 16 + ], 5)); + } + + if (this.version === 1 || this.version === 2) { + let key = CryptoJS.MD5(digest); + key.sigBytes = Math.min(16, this.keyBits / 8 + 5); + return buffer => wordArrayToBuffer( + CryptoJS.RC4.encrypt(CryptoJS.lib.WordArray.create(buffer), key).ciphertext); + } + + let key; + if (this.version === 4) { + key = CryptoJS.MD5(digest.concat(CryptoJS.lib.WordArray.create([0x73416c54], 4))); + } else { + key = this.encryptionKey; + } + + const iv = CryptoJS.lib.WordArray.random(16); + const options = { + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + iv + }; + + return buffer => wordArrayToBuffer( + iv.clone().concat(CryptoJS.AES.encrypt(CryptoJS.lib.WordArray.create(buffer), key, options).ciphertext)); + } + + end() { + this.dictionary.end(); + } +} + +function getPermissionsR2(options) { + let permissions = 0xffffffc0 >> 0; + if (options.allowPrinting) { + permissions |= 0b00000000010; + } + if (options.allowModifying) { + permissions |= 0b000000001000; + } + if (options.allowCopying) { + permissions |= 0b000000010000; + } + if (options.allowAnnotating) { + permissions |= 0b000000100000; + } + return permissions; +} + +function getPermissionsR3(options) { + let permissions = 0xfffff0c0 >> 0; + if (options.allowPrinting === 'lowResolution') { + permissions |= 0b000000000100; + } + if (options.allowPrinting === 'highResolution') { + permissions |= 0b100000000100; + } + if (options.allowModifying) { + permissions |= 0b000000001000; + } + if (options.allowCopying) { + permissions |= 0b000000010000; + } + if (options.allowAnnotating) { + permissions |= 0b000000100000; + } + if (options.allowFillingForms) { + permissions |= 0b000100000000; + } + if (options.allowContentAccessibility) { + permissions |= 0b001000000000; + } + if (options.allowDocumentAssembly) { + permissions |= 0b010000000000; + } + return permissions; +} + +function getUserPasswordR2(encryptionKey) { + return CryptoJS.RC4.encrypt(processPasswordR2R3R4(), encryptionKey).ciphertext; +} + +function getUserPasswordR3R4(documentId, encryptionKey) { + const key = encryptionKey.clone(); + let cipher = CryptoJS.MD5(processPasswordR2R3R4().concat(CryptoJS.lib.WordArray.create(documentId))); + for (let i = 0; i < 20; i++) { + const xorRound = Math.ceil(key.sigBytes / 4); + for (let j = 0; j < xorRound; j++) { + key.words[j] = encryptionKey.words[j] ^ (i | (i << 8) | (i << 16) | (i << 24)); + } + cipher = CryptoJS.RC4.encrypt(cipher, key).ciphertext; + } + return cipher.concat(CryptoJS.lib.WordArray.create(null, 16)); +} + +function getOwnerPasswordR2R3R4(r, keyBits, paddedUserPassword, paddedOwnerPassword) { + let digest = paddedOwnerPassword; + let round = r >= 3 ? 51 : 1; + for (let i = 0; i < round; i++) { + digest = CryptoJS.MD5(digest); + } + + const key = digest.clone(); + key.sigBytes = keyBits / 8; + let cipher = paddedUserPassword; + round = r >= 3 ? 20 : 1; + for (let i = 0; i < round; i++) { + const xorRound = Math.ceil(key.sigBytes / 4); + for (let j = 0; j < xorRound; j++) { + key.words[j] = digest.words[j] ^ (i | (i << 8) | (i << 16) | (i << 24)); + } + cipher = CryptoJS.RC4.encrypt(cipher, key).ciphertext; + } + return cipher; +} + +function getEncryptionKeyR2R3R4(r, keyBits, documentId, paddedUserPassword, ownerPasswordEntry, permissions) { + let key = paddedUserPassword.clone() + .concat(ownerPasswordEntry) + .concat(CryptoJS.lib.WordArray.create([lsbFirstWord(permissions)], 4)) + .concat(CryptoJS.lib.WordArray.create(documentId)); + const round = r >= 3 ? 51 : 1; + for (let i = 0; i < round; i++) { + key = CryptoJS.MD5(key); + key.sigBytes = keyBits / 8; + } + return key; +} + +function getUserPasswordR5(processedUserPassword) { + const validationSalt = CryptoJS.lib.WordArray.random(8); + const keySalt = CryptoJS.lib.WordArray.random(8); + return CryptoJS.SHA256(processedUserPassword.clone().concat(validationSalt)) + .concat(validationSalt).concat(keySalt); +} + +function getUserEncryptionKeyR5(processedUserPassword, userKeySalt, encryptionKey) { + const key = CryptoJS.SHA256(processedUserPassword.clone().concat(userKeySalt)); + const options = { + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.NoPadding, + iv: CryptoJS.lib.WordArray.create(null, 16) + }; + return CryptoJS.AES.encrypt(encryptionKey, key, options).ciphertext; +} + +function getOwnerPasswordR5(processedOwnerPassword, userPasswordEntry) { + const validationSalt = CryptoJS.lib.WordArray.random(8); + const keySalt = CryptoJS.lib.WordArray.random(8); + return CryptoJS.SHA256(processedOwnerPassword.clone().concat(validationSalt).concat(userPasswordEntry)) + .concat(validationSalt).concat(keySalt); +} + +function getOwnerEncryptionKeyR5(processedOwnerPassword, ownerKeySalt, userPasswordEntry, encryptionKey) { + const key = CryptoJS.SHA256(processedOwnerPassword.clone().concat(ownerKeySalt).concat(userPasswordEntry)); + const options = { + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.NoPadding, + iv: CryptoJS.lib.WordArray.create(null, 16) + }; + return CryptoJS.AES.encrypt(encryptionKey, key, options).ciphertext; +} + +function getEncryptionKeyR5() { + return CryptoJS.lib.WordArray.random(32); +} + +function getEncryptedPermissionsR5(permissions, encryptionKey) { + const cipher = CryptoJS.lib.WordArray.create([lsbFirstWord(permissions), 0xffffffff, 0x54616462], 12) + .concat(CryptoJS.lib.WordArray.random(4)); + const options = { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.NoPadding + }; + return CryptoJS.AES.encrypt(cipher, encryptionKey, options).ciphertext; +} + +function processPasswordR2R3R4(password = '') { + const out = new Buffer(32); + const length = password.length; + let index = 0; + while (index < length && index < 32) { + const code = password.charCodeAt(index); + if (code > 0xff) { + throw new Error('Password contains one or more invalid characters.'); + } + out[index] = code; + index++; + } + while (index < 32) { + out[index] = PASSWORD_PADDING[index - length]; + index++; + } + return CryptoJS.lib.WordArray.create(out); +} + +function processPasswordR5(password = '') { + password = unescape(encodeURIComponent(saslprep(password))); + const length = Math.min(127, password.length); + const out = new Buffer(length); + + for (let i = 0; i < length; i++) { + out[i] = password.charCodeAt(i); + } + + return CryptoJS.lib.WordArray.create(out); +} + +function lsbFirstWord(data) { + return ((data & 0xff) << 24) | ((data & 0xff00) << 8) | ((data >> 8) & 0xff00) | ((data >> 24) & 0xff); +} + +function wordArrayToBuffer(wordArray) { + const byteArray = []; + for (let i = 0; i < wordArray.sigBytes; i++) { + byteArray.push((wordArray.words[Math.floor(i / 4)] >> (8 * (3 - i % 4))) & 0xff); + } + return Buffer.from(byteArray); +} + +const PASSWORD_PADDING = [ + 0x28, 0xbf, 0x4e, 0x5e, 0x4e, 0x75, 0x8a, 0x41, 0x64, 0x00, 0x4e, 0x56, 0xff, 0xfa, 0x01, 0x08, + 0x2e, 0x2e, 0x00, 0xb6, 0xd0, 0x68, 0x3e, 0x80, 0x2f, 0x0c, 0xa9, 0xfe, 0x64, 0x53, 0x69, 0x7a +]; + +export default PDFSecurity; diff --git a/package.json b/package.json index 7eecc6b..db9d60c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "blob-stream": "^0.1.2", "brace": "^0.2.1", "brfs": "~2.0.1", - "browserify": "^3.39.0", + "browserify": "^13.3.0", "codemirror": "~3.20.0", "coffee-script": ">=1.0.1", "eslint": "^5.3.0", @@ -41,9 +41,11 @@ "rollup-plugin-cpy": "^1.0.0" }, "dependencies": { + "crypto-js": "^3.1.9-1", "fontkit": "^1.0.0", "linebreak": "^0.3.0", - "png-js": ">=0.1.0" + "png-js": ">=0.1.0", + "saslprep": "^1.0.0" }, "scripts": { "prepublishOnly": "npm run build",