diff --git a/README.md b/README.md index 23a3c60dd..c57d43495 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A JavaScript PDF generation library for Node and the browser. ## Description -PDFKit is a PDF document generation library for Node and the browser that makes creating complex, multi-page, printable documents easy. -It's written in CoffeeScript, but you can choose to use the API in plain 'ol JavaScript if you like. The API embraces -chainability, and includes both low level functions as well as abstractions for higher level functionality. The PDFKit API +PDFKit is a PDF document generation library for Node and the browser that makes creating complex, multi-page, printable documents easy. +It's written in CoffeeScript, but you can choose to use the API in plain 'ol JavaScript if you like. The API embraces +chainability, and includes both low level functions as well as abstractions for higher level functionality. The PDFKit API is designed to be simple, so generating complex documents is often as simple as a few function calls. Check out some of the [documentation and examples](http://pdfkit.org/docs/getting_started.html) to see for yourself! @@ -49,15 +49,17 @@ Installation uses the [npm](http://npmjs.org/) package manager. Just type the f * Underlines * etc. * Outlines - +* PDF security + * Encryption + * Access privileges (printing, copying, modifying, annotating, form filling, content accessibility, document assembly) + ## Coming soon! * Patterns fills -* PDF Security * Higher level APIs for creating tables and laying out content * More performance optimizations * Even more awesomeness, perhaps written by you! Please fork this repository and send me pull requests. - + ## Example ```coffeescript @@ -111,9 +113,9 @@ doc.addPage() # Finalize PDF file doc.end() ``` - -[The PDF output from this example](http://pdfkit.org/demo/out.pdf) (with a few additions) shows the power of PDFKit — producing -complex documents with a very small amount of code. For more, see the `demo` folder and the + +[The PDF output from this example](http://pdfkit.org/demo/out.pdf) (with a few additions) shows the power of PDFKit — producing +complex documents with a very small amount of code. For more, see the `demo` folder and the [PDFKit programming guide](http://pdfkit.org/docs/getting_started.html). ## Browser Usage @@ -122,13 +124,13 @@ There are two ways to use PDFKit in the browser. The first is to use [Browserif which is a Node module packager for the browser with the familiar `require` syntax. The second is to use a prebuilt version of PDFKit, which you can [download from Github](https://github.com/devongovett/pdfkit/releases). -In addition to PDFKit, you'll need somewhere to stream the output to. HTML5 has a +In addition to PDFKit, you'll need somewhere to stream the output to. HTML5 has a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) object which can be used to store binary data, and -get URLs to this data in order to display PDF output inside an iframe, or upload to a server, etc. In order to +get URLs to this data in order to display PDF output inside an iframe, or upload to a server, etc. In order to get a Blob from the output of PDFKit, you can use the [blob-stream](https://github.com/devongovett/blob-stream) module. -The following example uses Browserify to load `PDFKit` and `blob-stream`, but if you're not using Browserify, +The following example uses Browserify to load `PDFKit` and `blob-stream`, but if you're not using Browserify, you can load them in whatever way you'd like (e.g. script tags). ```coffeescript @@ -157,9 +159,9 @@ stream.on 'finish', -> You can see an interactive in-browser demo of PDFKit [here](http://pdfkit.org/demo/browser.html). -Note that in order to Browserify a project using PDFKit, you need to install the `brfs` module with npm, -which is used to load built-in font data into the package. It is listed as a `devDependency` in -PDFKit's `package.json`, so it isn't installed by default for Node users. +Note that in order to Browserify a project using PDFKit, you need to install the `brfs` module with npm, +which is used to load built-in font data into the package. It is listed as a `devDependency` in +PDFKit's `package.json`, so it isn't installed by default for Node users. If you forget to install it, Browserify will print an error message. ## Documentation diff --git a/docs/getting_started.coffee.md b/docs/getting_started.coffee.md index abe151530..d75814bd1 100644 --- a/docs/getting_started.coffee.md +++ b/docs/getting_started.coffee.md @@ -183,6 +183,71 @@ capitalized. * `CreationDate` - the date the document was created (added automatically by PDFKit) * `ModDate` - the date the document was last modified +## Encryption and Access Privileges + +PDF specification allow you to encrypt the PDF file and require a password when opening the file, +and/or set permissions of what users can do with the PDF file. PDFKit implements standard security +handler in PDF version 1.3 (40-bit RC4), version 1.4 (128-bit RC4), PDF version 1.7 (128-bit AES), +and PDF version 1.7 ExtensionLevel 3 (256-bit AES). + +To enable encryption, provide a user password when creating the `PDFDocument` in `options` object. +The PDF file will be encrypted when a user password is provided, and users will be prompted to enter +the password to decrypt the file when opening it. + + * `userPassword` - the user password (string value) + +To set access privileges for the PDF file, you need to provide an owner password and permission +settings in the `option` object when creating `PDFDocument`. By default, all operations are disallowed. +You need to explicitly allow certain operations. + + * `ownerPassword` - the owner password (string value) + * `permissions` - the object specifying PDF file permissions + +Following settings are allowed in `permissions` object: + + * `printing` - whether printing is allowed. Specify `"lowResolution"` to allow degraded printing, or `"highResolution"` to allow printing with high resolution + * `modifying` - whether modifying the file is allowed. Specify `true` to allow modifying document content + * `copying` - whether copying text or graphics is allowed. Specify `true` to allow copying + * `annotating` - whether annotating, form filling is allowed. Specify `true` to allow annotating and form filling + * `fillingForms` - whether form filling and signing is allowed. Specify `true` to allow filling in form fields and signing + * `contentAccessibility` - whether copying text for accessibility is allowed. Specify `true` to allow copying for accessibility + * `documentAssembly` - whether assembling document is allowed. Specify `true` to allow document assembly + +You can specify either user password, owner password or both passwords. +Behavior differs according to passwords you provides: + + * When only user password is provided, + users with user password are able to decrypt the file and have full access to the document. + * When only owner password is provided, + users are able to decrypt and open the document without providing any password, + but the access is limited to those operations explicitly permitted. + Users with owner password have full access to the document. + * When both passwords are provided, + users with user password are able to decrypt the file + but only have limited access to the file according to permission settings. + Users with owner password have full access to the document. + +Note that PDF file itself cannot enforce access privileges. +When file is decrypted, PDF viewer applications have full access to the file content, +and it is up to viewer applications to respect permission settings. + +To choose encryption method, you need to specify PDF version. +PDFKit will choose best encryption method available in the PDF version you specified. + + * `pdfVersion` - a string value specifying PDF file version + +Available options includes: + + * `1.3` - PDF version 1.3 (default), 40-bit RC4 is used + * `1.4` - PDF version 1.4, 128-bit RC4 is used + * `1.5` - PDF version 1.5, 128-bit RC4 is used + * `1.6` - PDF version 1.6, 128-bit AES is used + * `1.7` - PDF version 1.7, 128-bit AES is used + * `1.7ext3` - PDF version 1.7 ExtensionLevel 3, 256-bit AES is used + +When using PDF version 1.7 ExtensionLevel 3, password is truncated to 127 bytes of its UTF-8 representation. +In older versions, password is truncated to 32 bytes, and only Latin-1 characters are allowed. + ### Adding content Once you've created a `PDFDocument` instance, you can add content to the diff --git a/lib/document.js b/lib/document.js index e4d598673..43d670d58 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 f87816028..1491f110a 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 3958ce1f4..067fcb3ce 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 000000000..75f33d3b5 --- /dev/null +++ b/lib/security.js @@ -0,0 +1,404 @@ +/* + PDFSecurity - represents PDF security settings + By Yang Liu + */ + +import CryptoJS from 'crypto-js'; +import saslprep from 'saslprep'; + +class PDFSecurity { + static generateFileID(info = {}) { + let infoStr = `${info.CreationDate.getTime()}\n`; + + for (let key in info) { + if (!info.hasOwnProperty(key)) { + continue; + } + infoStr += `${key}: ${info[key].toString()}\n`; + } + + return wordArrayToBuffer(CryptoJS.MD5(infoStr)); + } + + static generateRandomWordArray(bytes) { + return CryptoJS.lib.WordArray.random(bytes); + } + + 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.permissions); + break; + case 2: + r = 3; + this.keyBits = 128; + permissions = getPermissionsR3(options.permissions); + break; + case 4: + r = 4; + this.keyBits = 128; + permissions = getPermissionsR3(options.permissions); + 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(PDFSecurity.generateRandomWordArray); + const userPasswordEntry = getUserPasswordR5(processedUserPassword, PDFSecurity.generateRandomWordArray); + const userKeySalt = CryptoJS.lib.WordArray.create(userPasswordEntry.words.slice(10, 12), 8); + const userEncryptionKeyEntry = getUserEncryptionKeyR5(processedUserPassword, userKeySalt, this.encryptionKey); + const ownerPasswordEntry = getOwnerPasswordR5(processedOwnerPassword, userPasswordEntry, + PDFSecurity.generateRandomWordArray); + 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, PDFSecurity.generateRandomWordArray); + + 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 = PDFSecurity.generateRandomWordArray(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(permissionObject = {}) { + let permissions = 0xffffffc0 >> 0; + if (permissionObject.printing) { + permissions |= 0b00000000010; + } + if (permissionObject.modifying) { + permissions |= 0b000000001000; + } + if (permissionObject.copying) { + permissions |= 0b000000010000; + } + if (permissionObject.annotating) { + permissions |= 0b000000100000; + } + return permissions; +} + +function getPermissionsR3(permissionObject = {}) { + let permissions = 0xfffff0c0 >> 0; + if (permissionObject.printing === 'lowResolution') { + permissions |= 0b000000000100; + } + if (permissionObject.printing === 'highResolution') { + permissions |= 0b100000000100; + } + if (permissionObject.modifying) { + permissions |= 0b000000001000; + } + if (permissionObject.copying) { + permissions |= 0b000000010000; + } + if (permissionObject.annotating) { + permissions |= 0b000000100000; + } + if (permissionObject.fillingForms) { + permissions |= 0b000100000000; + } + if (permissionObject.contentAccessibility) { + permissions |= 0b001000000000; + } + if (permissionObject.documentAssembly) { + 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, generateRandomWordArray) { + const validationSalt = generateRandomWordArray(8); + const keySalt = generateRandomWordArray(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, generateRandomWordArray) { + const validationSalt = generateRandomWordArray(8); + const keySalt = generateRandomWordArray(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(generateRandomWordArray) { + return generateRandomWordArray(32); +} + +function getEncryptedPermissionsR5(permissions, encryptionKey, generateRandomWordArray) { + const cipher = CryptoJS.lib.WordArray.create([lsbFirstWord(permissions), 0xffffffff, 0x54616462], 12) + .concat(generateRandomWordArray(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 7eecc6bf4..49a210474 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.1" }, "scripts": { "prepublishOnly": "npm run build", diff --git a/tests/__snapshots__/fonts.spec.js.snap b/tests/__snapshots__/fonts.spec.js.snap index 530cccb08..0c751752b 100644 Binary files a/tests/__snapshots__/fonts.spec.js.snap and b/tests/__snapshots__/fonts.spec.js.snap differ diff --git a/tests/__snapshots__/text.spec.js.snap b/tests/__snapshots__/text.spec.js.snap index 32d2bff7c..b331ab7f7 100644 Binary files a/tests/__snapshots__/text.spec.js.snap and b/tests/__snapshots__/text.spec.js.snap differ diff --git a/tests/__snapshots__/vector.spec.js.snap b/tests/__snapshots__/vector.spec.js.snap index 4ec160a9b..7cf40f092 100644 Binary files a/tests/__snapshots__/vector.spec.js.snap and b/tests/__snapshots__/vector.spec.js.snap differ diff --git a/tests/helpers.js b/tests/helpers.js index 75cb51995..7e558cc77 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -9,7 +9,7 @@ function updatePdf (pdfData, testState, snapshotChanges) { } const fileRefPath = path.join(pdfDir, testState.currentTestName + '.pdf'); - const fileChangesPath = fileRefPath.replace('.pdf', '[changed].pdf'); + const fileChangesPath = fileRefPath.replace('.pdf', '[changed].pdf'); const {matched, added, unmatched, updated} = snapshotChanges; @@ -41,13 +41,20 @@ function compareSnapshotChanges(changes, previousChanges) { }, {}) } -function runDocTest(fn) { +function runDocTest(options, fn) { + if (typeof options === 'function') { + fn = options; + options = {}; + } + if (!options.info) { + options.info = {}; + } + options.info.CreationDate = new Date(Date.UTC(2018,1,1)); + return new Promise(function(resolve) { - var doc = new PDFDocument; + var doc = new PDFDocument(options); var buffers = []; - doc.info.CreationDate = new Date(Date.UTC(2018,1,1)); - fn(doc); doc.on('data', buffers.push.bind(buffers)) diff --git a/tests/pdfmake/__snapshots__/absolute.spec.js.snap b/tests/pdfmake/__snapshots__/absolute.spec.js.snap index d4d0fc730..001e6f9ce 100644 Binary files a/tests/pdfmake/__snapshots__/absolute.spec.js.snap and b/tests/pdfmake/__snapshots__/absolute.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/background.spec.js.snap b/tests/pdfmake/__snapshots__/background.spec.js.snap index fafdbf907..c600ab3cc 100644 Binary files a/tests/pdfmake/__snapshots__/background.spec.js.snap and b/tests/pdfmake/__snapshots__/background.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/basics.spec.js.snap b/tests/pdfmake/__snapshots__/basics.spec.js.snap index f5750c215..16074a300 100644 Binary files a/tests/pdfmake/__snapshots__/basics.spec.js.snap and b/tests/pdfmake/__snapshots__/basics.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/columns_simple.spec.js.snap b/tests/pdfmake/__snapshots__/columns_simple.spec.js.snap index 612d245ce..9cd1a461e 100644 Binary files a/tests/pdfmake/__snapshots__/columns_simple.spec.js.snap and b/tests/pdfmake/__snapshots__/columns_simple.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/images.spec.js.snap b/tests/pdfmake/__snapshots__/images.spec.js.snap index 9cc000938..89415bba3 100644 Binary files a/tests/pdfmake/__snapshots__/images.spec.js.snap and b/tests/pdfmake/__snapshots__/images.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/lists.spec.js.snap b/tests/pdfmake/__snapshots__/lists.spec.js.snap index b1b6bfbf8..b1508befa 100644 Binary files a/tests/pdfmake/__snapshots__/lists.spec.js.snap and b/tests/pdfmake/__snapshots__/lists.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/page_references.spec.js.snap b/tests/pdfmake/__snapshots__/page_references.spec.js.snap index 56c51762f..28ca450ff 100644 Binary files a/tests/pdfmake/__snapshots__/page_references.spec.js.snap and b/tests/pdfmake/__snapshots__/page_references.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/qrcode.spec.js.snap b/tests/pdfmake/__snapshots__/qrcode.spec.js.snap index c76e55eee..e5e8bdc61 100644 Binary files a/tests/pdfmake/__snapshots__/qrcode.spec.js.snap and b/tests/pdfmake/__snapshots__/qrcode.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/security.spec.js.snap b/tests/pdfmake/__snapshots__/security.spec.js.snap new file mode 100644 index 000000000..a8df99fbb Binary files /dev/null and b/tests/pdfmake/__snapshots__/security.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/tables.spec.js.snap b/tests/pdfmake/__snapshots__/tables.spec.js.snap index de4d54d7a..ff64b6281 100644 Binary files a/tests/pdfmake/__snapshots__/tables.spec.js.snap and b/tests/pdfmake/__snapshots__/tables.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/text_decorations.spec.js.snap b/tests/pdfmake/__snapshots__/text_decorations.spec.js.snap index 76b1889bb..e39cdc555 100644 Binary files a/tests/pdfmake/__snapshots__/text_decorations.spec.js.snap and b/tests/pdfmake/__snapshots__/text_decorations.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/toc.spec.js.snap b/tests/pdfmake/__snapshots__/toc.spec.js.snap index 7146b5bcf..8ebe539bc 100644 Binary files a/tests/pdfmake/__snapshots__/toc.spec.js.snap and b/tests/pdfmake/__snapshots__/toc.spec.js.snap differ diff --git a/tests/pdfmake/__snapshots__/watermark.spec.js.snap b/tests/pdfmake/__snapshots__/watermark.spec.js.snap index dc71efc71..309eb130a 100644 Binary files a/tests/pdfmake/__snapshots__/watermark.spec.js.snap and b/tests/pdfmake/__snapshots__/watermark.spec.js.snap differ diff --git a/tests/pdfmake/security.spec.js b/tests/pdfmake/security.spec.js new file mode 100644 index 000000000..5fb9fbde4 --- /dev/null +++ b/tests/pdfmake/security.spec.js @@ -0,0 +1,56 @@ +var {runDocTest} = require('../helpers'); +var PDFDocument = require('../..'); +var CryptoJS = require('crypto-js'); + +describe('pdfmake', function () { + let generateRandomWordArray = null; + + beforeAll(function () { + const doc = new PDFDocument({ userPassword: 'user' }); + generateRandomWordArray = Object.getPrototypeOf(doc._security).constructor.generateRandomWordArray; + Object.getPrototypeOf(doc._security).constructor.generateRandomWordArray = function (bytes) { + return CryptoJS.lib.WordArray.create(null, bytes); + }; + }); + + afterAll(function () { + const doc = new PDFDocument({ userPassword: 'user' }); + Object.getPrototypeOf(doc._security).constructor.generateRandomWordArray = generateRandomWordArray; + }); + + test('encryption with RC-40 (PDF 1.3)', function () { + return runDocTest({ pdfVersion: '1.3', userPassword: 'user', ownerPassword: 'owner' }, function(doc) { + doc.text('test'); + }); + }); + + test('encryption with RC-128 (PDF 1.4)', function () { + return runDocTest({ pdfVersion: '1.4', userPassword: 'user', ownerPassword: 'owner' }, function(doc) { + doc.text('test'); + }); + }); + + test('encryption with RC-128 (PDF 1.5)', function () { + return runDocTest({ pdfVersion: '1.5', userPassword: 'user', ownerPassword: 'owner' }, function(doc) { + doc.text('test'); + }); + }); + + test('encryption with AES-128 (PDF 1.6)', function () { + return runDocTest({ pdfVersion: '1.6', userPassword: 'user', ownerPassword: 'owner' }, function(doc) { + doc.text('test'); + }); + }); + + test('encryption with AES-128 (PDF 1.7)', function () { + return runDocTest({ pdfVersion: '1.7', userPassword: 'user', ownerPassword: 'owner' }, function(doc) { + doc.text('test'); + }); + }); + + test('encryption with AES-256 (PDF 1.7 extension 3)', function () { + return runDocTest({ pdfVersion: '1.7ext3', userPassword: 'user', ownerPassword: 'owner' }, function(doc) { + doc.text('test'); + }); + }); +}); diff --git a/tests/unit/trailer.spec.js b/tests/unit/trailer.spec.js new file mode 100644 index 000000000..d485ebe7c --- /dev/null +++ b/tests/unit/trailer.spec.js @@ -0,0 +1,48 @@ +const PDFDocument = require('../../'); + +describe('Document trailer', () => { + let document; + + beforeEach(() => { + document = new PDFDocument({info: { CreationDate: new Date(Date.UTC(2018,1,1)) } }); + }); + + test('', (done) => { + const dataLog = []; + const expected = [ + [ + '7 0 obj', + '<<\n/Producer 8 0 R\n/Creator 9 0 R\n/CreationDate 10 0 R\n>>' + ], + [ + '8 0 obj', + '(PDFKit)' + ], + [ + '9 0 obj', + '(PDFKit)' + ], + [ + '10 0 obj', + '(D:20180201000000Z)' + ], + [ + 'trailer', + `<<\n/Size 11\n/Root 2 0 R\n/Info 7 0 R\n/ID [<8c72cf48ff87daac57e26bf1550e6979> <8c72cf48ff87daac57e26bf1550e6979>]\n>>` + ] + ]; + document._write = function(data) { + dataLog.push(data) + } + document.end(); + setTimeout(() => { + for (let i = 0; i < expected.length; ++i) { + let idx = dataLog.indexOf(expected[i][0]); + for (let j = 1; j < expected[i].length; ++j) { + expect(dataLog[idx + j]).toEqual(expected[i][j]); + } + } + done(); + }, 1); + }); +}); \ No newline at end of file