libs/pdfsecurity.js

/**
 * @license
 * Licensed under the MIT License.
 * http://opensource.org/licenses/mit-license
 * Author: Owen Leong (@owenl131)
 * Date: 15 Oct 2020
 * References:
 * https://www.cs.cmu.edu/~dst/Adobe/Gallery/anon21jul01-pdf-encryption.txt
 * https://github.com/foliojs/pdfkit/blob/master/lib/security.js
 * http://www.fpdf.org/en/script/script37.php
 */

import { md5Bin } from "./md5.js";
import { rc4 } from "./rc4.js";

var permissionOptions = {
  print: 4,
  modify: 8,
  copy: 16,
  "annot-forms": 32
};

/**
 * Initializes encryption settings
 *
 * @name constructor
 * @function
 * @param {Array} permissions Permissions allowed for user, "print", "modify", "copy" and "annot-forms".
 * @param {String} userPassword Permissions apply to this user. Leaving this empty means the document
 *                              is not password protected but viewer has the above permissions.
 * @param {String} ownerPassword Owner has full functionalities to the file.
 * @param {String} fileId As hex string, should be same as the file ID in the trailer.
 * @example
 * var security = new PDFSecurity(["print"])
 */
function PDFSecurity(permissions, userPassword, ownerPassword, fileId) {
  this.v = 1; // algorithm 1, future work can add in more recent encryption schemes
  this.r = 2; // revision 2

  // set flags for what functionalities the user can access
  let protection = 192;
  permissions.forEach(function(perm) {
    if (typeof permissionOptions.perm !== "undefined") {
      throw new Error("Invalid permission: " + perm);
    }
    protection += permissionOptions[perm];
  });

  // padding is used to pad the passwords to 32 bytes, also is hashed and stored in the final PDF
  this.padding =
    "\x28\xBF\x4E\x5E\x4E\x75\x8A\x41\x64\x00\x4E\x56\xFF\xFA\x01\x08" +
    "\x2E\x2E\x00\xB6\xD0\x68\x3E\x80\x2F\x0C\xA9\xFE\x64\x53\x69\x7A";
  let paddedUserPassword = (userPassword + this.padding).substr(0, 32);
  let paddedOwnerPassword = (ownerPassword + this.padding).substr(0, 32);

  this.O = this.processOwnerPassword(paddedUserPassword, paddedOwnerPassword);
  this.P = -((protection ^ 255) + 1);
  this.encryptionKey = md5Bin(
    paddedUserPassword +
      this.O +
      this.lsbFirstWord(this.P) +
      this.hexToBytes(fileId)
  ).substr(0, 5);
  this.U = rc4(this.encryptionKey, this.padding);
}

/**
 * Breaks down a 4-byte number into its individual bytes, with the least significant bit first
 *
 * @name lsbFirstWord
 * @function
 * @param {number} data 32-bit number
 * @returns {Array}
 */
PDFSecurity.prototype.lsbFirstWord = function(data) {
  return String.fromCharCode(
    (data >> 0) & 0xff,
    (data >> 8) & 0xff,
    (data >> 16) & 0xff,
    (data >> 24) & 0xff
  );
};

/**
 * Converts a byte string to a hex string
 *
 * @name toHexString
 * @function
 * @param {String} byteString Byte string
 * @returns {String}
 */
PDFSecurity.prototype.toHexString = function(byteString) {
  return byteString
    .split("")
    .map(function(byte) {
      return ("0" + (byte.charCodeAt(0) & 0xff).toString(16)).slice(-2);
    })
    .join("");
};

/**
 * Converts a hex string to a byte string
 *
 * @name hexToBytes
 * @function
 * @param {String} hex Hex string
 * @returns {String}
 */
PDFSecurity.prototype.hexToBytes = function(hex) {
  for (var bytes = [], c = 0; c < hex.length; c += 2)
    bytes.push(String.fromCharCode(parseInt(hex.substr(c, 2), 16)));
  return bytes.join("");
};

/**
 * Computes the 'O' field in the encryption dictionary
 *
 * @name processOwnerPassword
 * @function
 * @param {String} paddedUserPassword Byte string of padded user password
 * @param {String} paddedOwnerPassword Byte string of padded owner password
 * @returns {String}
 */
PDFSecurity.prototype.processOwnerPassword = function(
  paddedUserPassword,
  paddedOwnerPassword
) {
  let key = md5Bin(paddedOwnerPassword).substr(0, 5);
  return rc4(key, paddedUserPassword);
};

/**
 * Returns an encryptor function which can take in a byte string and returns the encrypted version
 *
 * @name encryptor
 * @function
 * @param {number} objectId
 * @param {number} generation Not sure what this is for, you can set it to 0
 * @returns {Function}
 * @example
 * out("stream");
 * encryptor = security.encryptor(object.id, 0);
 * out(encryptor(data));
 * out("endstream");
 */
PDFSecurity.prototype.encryptor = function(objectId, generation) {
  let key = md5Bin(
    this.encryptionKey +
      String.fromCharCode(
        objectId & 0xff,
        (objectId >> 8) & 0xff,
        (objectId >> 16) & 0xff,
        generation & 0xff,
        (generation >> 8) & 0xff
      )
  ).substr(0, 10);
  return function(data) {
    return rc4(key, data);
  };
};

export { PDFSecurity };