diff --git a/.github/workflows/monorepo.yml b/.github/workflows/monorepo.yml index e22f94c7..c586339d 100644 --- a/.github/workflows/monorepo.yml +++ b/.github/workflows/monorepo.yml @@ -35,6 +35,14 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} base-path: packages/placeholder-pdfkit010 path-to-lcov: packages/placeholder-pdfkit010/coverage/lcov.info + - name: Coveralls (placeholder-pdfkit) + uses: coverallsapp/github-action@master + with: + parallel: true + flag-name: placeholder-pdfkit + github-token: ${{ secrets.GITHUB_TOKEN }} + base-path: packages/placeholder-pdfkit + path-to-lcov: packages/placeholder-pdfkit/coverage/lcov.info - name: Coveralls (placeholder-plain) uses: coverallsapp/github-action@master with: @@ -55,4 +63,4 @@ jobs: uses: coverallsapp/github-action@master with: parallel-finished: true - carryforward: "signpdf,utils,placeholder-pdfkit010,placeholder-plain" + carryforward: "signpdf,utils,placeholder-pdfkit010,placeholder-pdfkit,placeholder-plain" diff --git a/packages/placeholder-pdfkit/.babelrc b/packages/placeholder-pdfkit/.babelrc new file mode 100644 index 00000000..00bdc749 --- /dev/null +++ b/packages/placeholder-pdfkit/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../babel.config.json" +} \ No newline at end of file diff --git a/packages/placeholder-pdfkit/.eslintrc b/packages/placeholder-pdfkit/.eslintrc new file mode 100644 index 00000000..da587e05 --- /dev/null +++ b/packages/placeholder-pdfkit/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "@signpdf/eslint-config" + ] +} \ No newline at end of file diff --git a/packages/placeholder-pdfkit/README.md b/packages/placeholder-pdfkit/README.md new file mode 100644 index 00000000..380c20ea --- /dev/null +++ b/packages/placeholder-pdfkit/README.md @@ -0,0 +1,37 @@ +# Placehodler providing helper using PDFKit 0.11+ + +for [![@signpdf](https://raw.githubusercontent.com/vbuch/node-signpdf/master/resources/logo-horizontal.svg?sanitize=true)](https://github.com/vbuch/node-signpdf/) + +[![npm version](https://badge.fury.io/js/@signpdf%2Fplaceholder-pdfkit010.svg)](https://badge.fury.io/js/@signpdf%2Fplaceholder-pdfkit010) +[![Donate to this project using Buy Me A Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg)](https://buymeacoffee.com/vbuch) + +Works on top of `PDFKit 0.11+` and given a PDFDocument that is in the works, adds an e-signature placeholder. When the PDF is ready you can pass it to `@signpdf/signpdf` to complete the process. + +## Usage + +You will need `$ npm i -S @signpdf/signpdf @signpdf/placeholder-pdfkit node-forge`. This works in an identical way to the pdfkit010 package and the [pdfkit010.js example](/packages/examples/pdfkit010.js) is still relevant. + +## Notes + +* Make sure to have a look at the docs of the [@signpdf family of packages](https://github.com/vbuch/node-signpdf/). +* Feel free to copy and paste any part of this code. See its defined [Purpose](https://github.com/vbuch/node-signpdf#purpose). + +### Signature length + +Signing in detached mode makes the signature length independent of the PDF's content length, but it may still vary between different signing certificates. So every time you sign using the same P12 you will get the same length of the output signature, no matter the length of the signed content. It is safe to find out the actual signature length your certificate produces and use it to properly configure the placeholder length. + +### PAdES compliant signatures + +To produce PAdES compliant signatures, the ETSI Signature Dictionary SubFilter value must be `ETSI.CAdES.detached` instead of the standard Adobe value. + +This can be declared using the subFilter option argument passed to `pdfkitAddPlaceholder` and `plainAddPlaceholder`. + +```js +import { pdfkitAddPlaceholder } from '@signpdf/placeholder-pdfkit'; +import { SUBFILTER_ETSI_CADES_DETACHED } from '@signpdf/utils'; + +const pdfToSign = pdfkitAddPlaceholder({ + ..., + subFilter: SUBFILTER_ETSI_CADES_DETACHED, +}); +``` diff --git a/packages/placeholder-pdfkit/dist/index.d.ts b/packages/placeholder-pdfkit/dist/index.d.ts new file mode 100644 index 00000000..a831c9e6 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/index.d.ts @@ -0,0 +1,3 @@ +export * from "./pdfkitAddPlaceholder"; +export { default as PDFObject } from "./pdfkit/pdfobject"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/index.d.ts.map b/packages/placeholder-pdfkit/dist/index.d.ts.map new file mode 100644 index 00000000..6e08bd54 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":""} \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/index.js b/packages/placeholder-pdfkit/dist/index.js new file mode 100644 index 00000000..9913d9a0 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/index.js @@ -0,0 +1,28 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = { + PDFObject: true +}; +Object.defineProperty(exports, "PDFObject", { + enumerable: true, + get: function () { + return _pdfobject.default; + } +}); +var _pdfkitAddPlaceholder = require("./pdfkitAddPlaceholder"); +Object.keys(_pdfkitAddPlaceholder).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _pdfkitAddPlaceholder[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _pdfkitAddPlaceholder[key]; + } + }); +}); +var _pdfobject = _interopRequireDefault(require("./pdfkit/pdfobject")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.d.ts b/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.d.ts new file mode 100644 index 00000000..6f20e700 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.d.ts @@ -0,0 +1,6 @@ +export default PDFAbstractReference; +declare class PDFAbstractReference { + toString(): void; + end(): void; +} +//# sourceMappingURL=abstract_reference.d.ts.map \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.d.ts.map b/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.d.ts.map new file mode 100644 index 00000000..eb6055a6 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"abstract_reference.d.ts","sourceRoot":"","sources":["../../src/pdfkit/abstract_reference.js"],"names":[],"mappings":";AAWA;IACI,iBAEC;IAED,YAEC;CACJ"} \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.js b/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.js new file mode 100644 index 00000000..10de9b71 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkit/abstract_reference.js @@ -0,0 +1,26 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +/* +PDFAbstractReference by Devon Govett used below. +The class is part of pdfkit. See https://github.com/foliojs/pdfkit +LICENSE: MIT. Included in this folder. +Modifications may have been applied for the purposes of node-signpdf. +*/ + +/* +PDFAbstractReference - abstract class for PDF reference +*/ + +class PDFAbstractReference { + toString() { + throw new Error('Must be implemented by subclasses'); + } + end() { + // noop + } +} +var _default = exports.default = PDFAbstractReference; \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.d.ts b/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.d.ts new file mode 100644 index 00000000..7ecdae38 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.d.ts @@ -0,0 +1,5 @@ +export default class PDFObject { + static convert(object: any, encryptFn?: any): any; + static number(n: any): number; +} +//# sourceMappingURL=pdfobject.d.ts.map \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.d.ts.map b/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.d.ts.map new file mode 100644 index 00000000..b4915996 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"pdfobject.d.ts","sourceRoot":"","sources":["../../src/pdfkit/pdfobject.js"],"names":[],"mappings":"AA8BA;IACI,kDAuFC;IAED,8BAMC;CACJ"} \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.js b/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.js new file mode 100644 index 00000000..dc70f68d --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkit/pdfobject.js @@ -0,0 +1,134 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _abstract_reference = _interopRequireDefault(require("./abstract_reference")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/* +PDFObject by Devon Govett used below. +The class is part of pdfkit. See https://github.com/foliojs/pdfkit +LICENSE: MIT. Included in this folder. +Modifications may have been applied for the purposes of node-signpdf. +*/ + +/* +PDFObject - converts JavaScript types into their corresponding PDF types. +By Devon Govett +*/ + +const pad = (str, length) => (Array(length + 1).join('0') + str).slice(-length); +const escapableRe = /[\n\r\t\b\f()\\]/g; +const escapable = { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\b': '\\b', + '\f': '\\f', + '\\': '\\\\', + '(': '\\(', + ')': '\\)' +}; + +// Convert little endian UTF-16 to big endian +const swapBytes = buff => buff.swap16(); +class PDFObject { + static convert(object, encryptFn = null) { + // String literals are converted to the PDF name type + if (typeof object === 'string') { + return `/${object}`; + + // String objects are converted to PDF strings (UTF-16) + } + if (object instanceof String) { + let string = object; + // Detect if this is a unicode string + let isUnicode = false; + for (let i = 0, end = string.length; i < end; i += 1) { + if (string.charCodeAt(i) > 0x7f) { + isUnicode = true; + break; + } + } + + // If so, encode it as big endian UTF-16 + let stringBuffer; + if (isUnicode) { + stringBuffer = swapBytes(Buffer.from(`\ufeff${string}`, 'utf16le')); + } else { + stringBuffer = Buffer.from(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 + string = string.replace(escapableRe, c => escapable[c]); + return `(${string})`; + + // Buffers are converted to PDF hex strings + } + if (Buffer.isBuffer(object)) { + return `<${object.toString('hex')}>`; + } + if (object instanceof _abstract_reference.default) { + return object.toString(); + } + if (object instanceof Date) { + 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(Buffer.from(string, 'ascii')).toString('binary'); + + // Escape characters as required by the spec + string = string.replace(escapableRe, c => escapable[c]); + } + return `(${string})`; + } + if (Array.isArray(object)) { + const items = object.map(e => PDFObject.convert(e, encryptFn)).join(' '); + return `[${items}]`; + } + if ({}.toString.call(object) === '[object Object]') { + const out = ['<<']; + let streamData; + + // @todo this can probably be refactored into a reduce + Object.entries(object).forEach(([key, val]) => { + let checkedValue = ''; + if (val.toString().indexOf('<<') !== -1) { + checkedValue = val; + } else { + checkedValue = PDFObject.convert(val, encryptFn); + } + if (key === 'stream') { + streamData = `${key}\n${val}\nendstream`; + } else { + out.push(`/${key} ${checkedValue}`); + } + }); + out.push('>>'); + if (streamData) { + out.push(streamData); + } + return out.join('\n'); + } + if (typeof object === 'number') { + return PDFObject.number(object); + } + return `${object}`; + } + static number(n) { + if (n > -1e21 && n < 1e21) { + return Math.round(n * 1e6) / 1e6; + } + throw new Error(`unsupported number: ${n}`); + } +} +exports.default = PDFObject; \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.d.ts b/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.d.ts new file mode 100644 index 00000000..9c831b12 --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.d.ts @@ -0,0 +1,28 @@ +export function pdfkitAddPlaceholder({ pdf, reason, contactInfo, name, location, signatureLength, byteRangePlaceholder, subFilter, widgetRect, }: InputType): ReturnType; +export type InputType = { + /** + * PDFDocument + */ + pdf: object; + pdfBuffer: Buffer; + reason: string; + contactInfo: string; + name: string; + location: string; + signatureLength?: number; + byteRangePlaceholder?: string; + /** + * One of SUBFILTER_* from \@signpdf/utils + */ + subFilter?: string; + /** + * [x1, y1, x2, y2] widget rectangle + */ + widgetRect?: number[]; +}; +export type ReturnType = { + signature: any; + form: any; + widget: any; +}; +//# sourceMappingURL=pdfkitAddPlaceholder.d.ts.map \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.d.ts.map b/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.d.ts.map new file mode 100644 index 00000000..7c6ae9ba --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"pdfkitAddPlaceholder.d.ts","sourceRoot":"","sources":["../src/pdfkitAddPlaceholder.js"],"names":[],"mappings":"AAmCO,kJAHI,SAAS,GACP,UAAU,CAqEtB;;;;;SA7FY,MAAM;eACN,MAAM;YACN,MAAM;iBACN,MAAM;UACN,MAAM;cACN,MAAM;sBACN,MAAM;2BACN,MAAM;;;;gBACN,MAAM;;;;iBACN,MAAM,EAAE;;;eAKR,GAAG;UACH,GAAG;YACH,GAAG"} \ No newline at end of file diff --git a/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.js b/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.js new file mode 100644 index 00000000..58da0f1e --- /dev/null +++ b/packages/placeholder-pdfkit/dist/pdfkitAddPlaceholder.js @@ -0,0 +1,100 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.pdfkitAddPlaceholder = void 0; +var _utils = require("@signpdf/utils"); +/** +* @typedef {object} InputType +* @property {object} pdf PDFDocument +* @property {Buffer} pdfBuffer +* @property {string} reason +* @property {string} contactInfo +* @property {string} name +* @property {string} location +* @property {number} [signatureLength] +* @property {string} [byteRangePlaceholder] +* @property {string} [subFilter] One of SUBFILTER_* from \@signpdf/utils +* @property {number[]} [widgetRect] [x1, y1, x2, y2] widget rectangle +*/ + +/** +* @typedef {object} ReturnType +* @property {any} signature +* @property {any} form +* @property {any} widget + */ + +/** + * Adds the objects that are needed for Adobe.PPKLite to read the signature. + * Also includes a placeholder for the actual signature. + * Returns an Object with all the added PDFReferences. + * @param {InputType} + * @returns {ReturnType} + */ +const pdfkitAddPlaceholder = ({ + pdf, + reason, + contactInfo, + name, + location, + signatureLength = _utils.DEFAULT_SIGNATURE_LENGTH, + byteRangePlaceholder = _utils.DEFAULT_BYTE_RANGE_PLACEHOLDER, + subFilter = _utils.SUBFILTER_ADOBE_PKCS7_DETACHED, + widgetRect = [0, 0, 0, 0] +}) => { + /* eslint-disable no-underscore-dangle,no-param-reassign */ + // Generate the signature placeholder + const signature = pdf.ref({ + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: subFilter, + ByteRange: [0, byteRangePlaceholder, byteRangePlaceholder, byteRangePlaceholder], + Contents: Buffer.from(String.fromCharCode(0).repeat(signatureLength)), + Reason: new String(reason), + // eslint-disable-line no-new-wrappers + M: new Date(), + ContactInfo: new String(contactInfo), + // eslint-disable-line no-new-wrappers + Name: new String(name), + // eslint-disable-line no-new-wrappers + Location: new String(location) // eslint-disable-line no-new-wrappers + }); + + if (!pdf._acroform) { + pdf.initForm(); + } + const form = pdf._root.data.AcroForm; + const fieldId = form.data.Fields.length + 1; + const signatureName = `Signature${fieldId}`; + form.data = { + Type: 'AcroForm', + SigFlags: _utils.SIG_FLAGS.SIGNATURES_EXIST | _utils.SIG_FLAGS.APPEND_ONLY, + Fields: form.data.Fields, + DR: form.data.DR + }; + + // Generate signature annotation widget + const widget = pdf.ref({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Sig', + Rect: widgetRect, + V: signature, + T: new String(signatureName), + // eslint-disable-line no-new-wrappers + P: pdf.page.dictionary + }); + pdf.page.annotations.push(widget); + form.data.Fields.push(widget); + signature.end(); + widget.end(); + return { + signature, + form, + widget + }; + /* eslint-enable no-underscore-dangle,no-param-reassign */ +}; +exports.pdfkitAddPlaceholder = pdfkitAddPlaceholder; \ No newline at end of file diff --git a/packages/placeholder-pdfkit/jest.config.js b/packages/placeholder-pdfkit/jest.config.js new file mode 100644 index 00000000..1706e84f --- /dev/null +++ b/packages/placeholder-pdfkit/jest.config.js @@ -0,0 +1,17 @@ +const sharedConfig = require('../../jest.config.base'); +module.exports = { + ...sharedConfig, + coveragePathIgnorePatterns: [ + ...sharedConfig.coveragePathIgnorePatterns, + '/src/pdfkit/', + ], + coverageThreshold: { + ...sharedConfig.coverageThreshold, + '**/pdfkitAddPlaceholder.js': { + functions: 100, + lines: 96.77, + statements: 96.77, + branches: 88.88, + } + } +}; \ No newline at end of file diff --git a/packages/placeholder-pdfkit/package.json b/packages/placeholder-pdfkit/package.json new file mode 100644 index 00000000..87351f4f --- /dev/null +++ b/packages/placeholder-pdfkit/package.json @@ -0,0 +1,73 @@ +{ + "name": "@signpdf/placeholder-pdfkit", + "version": "3.0.0", + "description": "Use foliojs/PDFKit 0.11+ to insert a signature placeholder.", + "repository": { + "type": "git", + "url": "https://github.com/vbuch/node-signpdf" + }, + "license": "MIT", + "keywords": [ + "sign", + "pdf", + "node", + "nodejs", + "esign", + "adobe", + "ppklite", + "sign detached", + "pkcs7", + "pkcs#7", + "pades", + "digital signature" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=12", + "yarn": ">=1.22.18" + }, + "scripts": { + "test": "jest", + "build": "rm -rf ./dist/* & babel ./src -d ./dist --ignore \"**/*.test.js\" & tsc", + "lint": "eslint -c .eslintrc --ignore-path ../../.eslintignore ./" + }, + "dependencies": { + "@signpdf/utils": "^3.0.0" + }, + "peerDependencies": { + "pdfkit": "^0.11.0" + }, + "devDependencies": { + "@babel/cli": "^7.0.0", + "@babel/core": "^7.4.0", + "@babel/eslint-parser": "^7.16.3", + "@babel/node": "^7.0.0", + "@babel/plugin-syntax-object-rest-spread": "^7.0.0", + "@babel/preset-env": "^7.4.2", + "@signpdf/eslint-config": "^3.0.0", + "@signpdf/internal-utils": "^3.0.0", + "@types/node": ">=12.0.0", + "@types/node-forge": "^1.2.1", + "assertion-error": "^1.1.0", + "babel-jest": "^27.3.1", + "babel-plugin-module-resolver": "^3.1.1", + "coveralls": "^3.0.2", + "eslint": "^8.50.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-import-resolver-babel-module": "^5.3.1", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.2.4", + "husky": "^7.0.4", + "jest": "^27.3.1", + "node-forge": "^1.2.1", + "pdfkit": "^0.11.0", + "typescript": "^5.2.2" + }, + "gitHead": "cf8d5f40b2e2e513919ba586df03bad4fb87982c" +} diff --git a/packages/placeholder-pdfkit/src/index.js b/packages/placeholder-pdfkit/src/index.js new file mode 100644 index 00000000..d1902932 --- /dev/null +++ b/packages/placeholder-pdfkit/src/index.js @@ -0,0 +1,2 @@ +export * from './pdfkitAddPlaceholder'; +export {default as PDFObject} from './pdfkit/pdfobject'; diff --git a/packages/placeholder-pdfkit/src/pdfkit/LICENSE b/packages/placeholder-pdfkit/src/pdfkit/LICENSE new file mode 100644 index 00000000..5f5982ee --- /dev/null +++ b/packages/placeholder-pdfkit/src/pdfkit/LICENSE @@ -0,0 +1,8 @@ +MIT LICENSE +Copyright (c) 2014 Devon Govett + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/placeholder-pdfkit/src/pdfkit/abstract_reference.js b/packages/placeholder-pdfkit/src/pdfkit/abstract_reference.js new file mode 100644 index 00000000..04a26aa6 --- /dev/null +++ b/packages/placeholder-pdfkit/src/pdfkit/abstract_reference.js @@ -0,0 +1,22 @@ +/* +PDFAbstractReference by Devon Govett used below. +The class is part of pdfkit. See https://github.com/foliojs/pdfkit +LICENSE: MIT. Included in this folder. +Modifications may have been applied for the purposes of node-signpdf. +*/ + +/* +PDFAbstractReference - abstract class for PDF reference +*/ + +class PDFAbstractReference { + toString() { + throw new Error('Must be implemented by subclasses'); + } + + end() { + // noop + } +} + +export default PDFAbstractReference; diff --git a/packages/placeholder-pdfkit/src/pdfkit/pdfobject.js b/packages/placeholder-pdfkit/src/pdfkit/pdfobject.js new file mode 100644 index 00000000..68751c90 --- /dev/null +++ b/packages/placeholder-pdfkit/src/pdfkit/pdfobject.js @@ -0,0 +1,128 @@ +/* +PDFObject by Devon Govett used below. +The class is part of pdfkit. See https://github.com/foliojs/pdfkit +LICENSE: MIT. Included in this folder. +Modifications may have been applied for the purposes of node-signpdf. +*/ + +import PDFAbstractReference from './abstract_reference'; +/* +PDFObject - converts JavaScript types into their corresponding PDF types. +By Devon Govett +*/ + +const pad = (str, length) => (Array(length + 1).join('0') + str).slice(-length); + +const escapableRe = /[\n\r\t\b\f()\\]/g; +const escapable = { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\b': '\\b', + '\f': '\\f', + '\\': '\\\\', + '(': '\\(', + ')': '\\)', +}; + +// Convert little endian UTF-16 to big endian +const swapBytes = (buff) => buff.swap16(); + +export default class PDFObject { + static convert(object, encryptFn = null) { + // String literals are converted to the PDF name type + if (typeof object === 'string') { + return `/${object}`; + + // String objects are converted to PDF strings (UTF-16) + } if (object instanceof String) { + let string = object; + // Detect if this is a unicode string + let isUnicode = false; + for (let i = 0, end = string.length; i < end; i += 1) { + if (string.charCodeAt(i) > 0x7f) { + isUnicode = true; + break; + } + } + + // If so, encode it as big endian UTF-16 + let stringBuffer; + if (isUnicode) { + stringBuffer = swapBytes(Buffer.from(`\ufeff${string}`, 'utf16le')); + } else { + stringBuffer = Buffer.from(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 + string = string.replace(escapableRe, (c) => escapable[c]); + + return `(${string})`; + + // Buffers are converted to PDF hex strings + } if (Buffer.isBuffer(object)) { + return `<${object.toString('hex')}>`; + } if (object instanceof PDFAbstractReference) { + return object.toString(); + } if (object instanceof Date) { + 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(Buffer.from(string, 'ascii')).toString('binary'); + + // Escape characters as required by the spec + string = string.replace(escapableRe, (c) => escapable[c]); + } + + return `(${string})`; + } if (Array.isArray(object)) { + const items = object.map((e) => PDFObject.convert(e, encryptFn)).join(' '); + return `[${items}]`; + } if ({}.toString.call(object) === '[object Object]') { + const out = ['<<']; + let streamData; + + // @todo this can probably be refactored into a reduce + Object.entries(object).forEach(([key, val]) => { + let checkedValue = ''; + + if (val.toString().indexOf('<<') !== -1) { + checkedValue = val; + } else { + checkedValue = PDFObject.convert(val, encryptFn); + } + + if (key === 'stream') { + streamData = `${key}\n${val}\nendstream`; + } else { + out.push(`/${key} ${checkedValue}`); + } + }); + out.push('>>'); + + if (streamData) { + out.push(streamData); + } + return out.join('\n'); + } if (typeof object === 'number') { + return PDFObject.number(object); + } + return `${object}`; + } + + static number(n) { + if (n > -1e21 && n < 1e21) { + return Math.round(n * 1e6) / 1e6; + } + + throw new Error(`unsupported number: ${n}`); + } +} diff --git a/packages/placeholder-pdfkit/src/pdfkitAddPlaceholder.js b/packages/placeholder-pdfkit/src/pdfkitAddPlaceholder.js new file mode 100644 index 00000000..333edc8e --- /dev/null +++ b/packages/placeholder-pdfkit/src/pdfkitAddPlaceholder.js @@ -0,0 +1,103 @@ +import { + DEFAULT_BYTE_RANGE_PLACEHOLDER, + DEFAULT_SIGNATURE_LENGTH, + SIG_FLAGS, + SUBFILTER_ADOBE_PKCS7_DETACHED, +} from '@signpdf/utils'; + +/** +* @typedef {object} InputType +* @property {object} pdf PDFDocument +* @property {Buffer} pdfBuffer +* @property {string} reason +* @property {string} contactInfo +* @property {string} name +* @property {string} location +* @property {number} [signatureLength] +* @property {string} [byteRangePlaceholder] +* @property {string} [subFilter] One of SUBFILTER_* from \@signpdf/utils +* @property {number[]} [widgetRect] [x1, y1, x2, y2] widget rectangle +*/ + +/** +* @typedef {object} ReturnType +* @property {any} signature +* @property {any} form +* @property {any} widget + */ + +/** + * Adds the objects that are needed for Adobe.PPKLite to read the signature. + * Also includes a placeholder for the actual signature. + * Returns an Object with all the added PDFReferences. + * @param {InputType} + * @returns {ReturnType} + */ +export const pdfkitAddPlaceholder = ({ + pdf, + reason, + contactInfo, + name, + location, + signatureLength = DEFAULT_SIGNATURE_LENGTH, + byteRangePlaceholder = DEFAULT_BYTE_RANGE_PLACEHOLDER, + subFilter = SUBFILTER_ADOBE_PKCS7_DETACHED, + widgetRect = [0, 0, 0, 0], +}) => { + /* eslint-disable no-underscore-dangle,no-param-reassign */ + // Generate the signature placeholder + const signature = pdf.ref({ + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: subFilter, + ByteRange: [ + 0, + byteRangePlaceholder, + byteRangePlaceholder, + byteRangePlaceholder, + ], + Contents: Buffer.from(String.fromCharCode(0).repeat(signatureLength)), + Reason: new String(reason), // eslint-disable-line no-new-wrappers + M: new Date(), + ContactInfo: new String(contactInfo), // eslint-disable-line no-new-wrappers + Name: new String(name), // eslint-disable-line no-new-wrappers + Location: new String(location), // eslint-disable-line no-new-wrappers + }); + + if (!pdf._acroform) { + pdf.initForm(); + } + + const form = pdf._root.data.AcroForm; + const fieldId = form.data.Fields.length + 1; + const signatureName = `Signature${fieldId}`; + form.data = { + Type: 'AcroForm', + SigFlags: SIG_FLAGS.SIGNATURES_EXIST | SIG_FLAGS.APPEND_ONLY, + Fields: form.data.Fields, + DR: form.data.DR, + }; + + // Generate signature annotation widget + const widget = pdf.ref({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Sig', + Rect: widgetRect, + V: signature, + T: new String(signatureName), // eslint-disable-line no-new-wrappers + P: pdf.page.dictionary, + }); + + pdf.page.annotations.push(widget); + form.data.Fields.push(widget); + signature.end(); + widget.end(); + + return { + signature, + form, + widget, + }; + /* eslint-enable no-underscore-dangle,no-param-reassign */ +}; diff --git a/packages/placeholder-pdfkit/src/pdfkitAddPlaceholder.test.js b/packages/placeholder-pdfkit/src/pdfkitAddPlaceholder.test.js new file mode 100644 index 00000000..07d3e508 --- /dev/null +++ b/packages/placeholder-pdfkit/src/pdfkitAddPlaceholder.test.js @@ -0,0 +1,155 @@ +import {SIG_FLAGS, SUBFILTER_ADOBE_PKCS7_DETACHED, SUBFILTER_ETSI_CADES_DETACHED} from '@signpdf/utils'; +import {createPdfkitDocument} from '@signpdf/internal-utils'; +import PDFDocument from 'pdfkit'; +import {pdfkitAddPlaceholder} from './pdfkitAddPlaceholder'; +import PDFObject from './pdfkit/pdfobject'; + +describe(pdfkitAddPlaceholder, () => { + const defaults = { + contactInfo: 'testemail@example.com', + name: 'test name', + location: 'test Location', + }; + + it('adds placeholder to PDFKit document', () => { + const {pdf} = createPdfkitDocument(PDFDocument, {}); + + const refs = pdfkitAddPlaceholder({ + ...defaults, + pdf, + pdfBuffer: Buffer.from([pdf]), + }); + expect(Object.keys(refs)).toEqual(expect.arrayContaining([ + 'signature', + 'form', + 'widget', + ])); + expect(pdf.page.dictionary.data.Annots).toHaveLength(1); + expect(pdf.page.dictionary.data.Annots[0].data.Subtype).toEqual('Widget'); + expect(pdf.page.dictionary.data.Annots[0].data.V.data.ByteRange).toEqual([ + 0, + '**********', + '**********', + '**********', + ]); + }); + + it('placeholder contains reason, contactInfo, name, location', () => { + const {pdf} = createPdfkitDocument(PDFDocument, {}); + + const refs = pdfkitAddPlaceholder({ + ...defaults, + pdf, + pdfBuffer: Buffer.from([pdf]), + reason: 'test reason', + }); + expect(Object.keys(refs)).toEqual(expect.arrayContaining([ + 'signature', + 'form', + 'widget', + ])); + expect(pdf.page.dictionary.data.Annots).toHaveLength(1); + expect(pdf.page.dictionary.data.Annots[0].data.Subtype).toEqual('Widget'); + const widgetData = pdf.page.dictionary.data.Annots[0].data.V.data; + expect(PDFObject.convert(widgetData.Reason)).toEqual('(test reason)'); + expect(PDFObject.convert(widgetData.ContactInfo)).toEqual(`(${defaults.contactInfo})`); + expect(PDFObject.convert(widgetData.Name)).toEqual(`(${defaults.name})`); + expect(PDFObject.convert(widgetData.Location)).toEqual(`(${defaults.location})`); + expect(widgetData.SubFilter).toEqual(SUBFILTER_ADOBE_PKCS7_DETACHED); + }); + + it('allows defining signature SubFilter', () => { + const {pdf} = createPdfkitDocument(PDFDocument, {}); + + const refs = pdfkitAddPlaceholder({ + ...defaults, + pdf, + pdfBuffer: Buffer.from([pdf]), + reason: 'test reason', + subFilter: SUBFILTER_ETSI_CADES_DETACHED, + }); + expect(Object.keys(refs)).toEqual(expect.arrayContaining([ + 'signature', + 'form', + 'widget', + ])); + expect(pdf.page.dictionary.data.Annots).toHaveLength(1); + const widget = pdf.page.dictionary.data.Annots[0]; + expect(widget.data.Subtype).toEqual('Widget'); + const widgetData = widget.data.V.data; + expect(PDFObject.convert(widgetData.Reason)).toEqual('(test reason)'); + expect(widgetData.SubFilter).toEqual(SUBFILTER_ETSI_CADES_DETACHED); + }); + + it('sets the widget rectange to invisible by default', () => { + const {pdf} = createPdfkitDocument(PDFDocument, {}); + const refs = pdfkitAddPlaceholder({ + ...defaults, + pdf, + pdfBuffer: Buffer.from([pdf]), + reason: 'test reason', + }); + expect(Object.keys(refs)).toEqual(expect.arrayContaining([ + 'signature', + 'form', + 'widget', + ])); + expect(pdf.page.dictionary.data.Annots).toHaveLength(1); + const widget = pdf.page.dictionary.data.Annots[0]; + const rect = widget.data.Rect; + expect(Array.isArray(rect)).toBe(true); + expect(rect).toEqual([0, 0, 0, 0]); + }); + + it('allows defining widget rectange', () => { + const {pdf} = createPdfkitDocument(PDFDocument, {}); + const widgetRect = [100, 100, 200, 200]; + const refs = pdfkitAddPlaceholder({ + ...defaults, + pdf, + pdfBuffer: Buffer.from([pdf]), + reason: 'test reason', + widgetRect, + }); + expect(Object.keys(refs)).toEqual(expect.arrayContaining([ + 'signature', + 'form', + 'widget', + ])); + expect(pdf.page.dictionary.data.Annots).toHaveLength(1); + const widget = pdf.page.dictionary.data.Annots[0]; + const rect = widget.data.Rect; + expect(Array.isArray(rect)).toBe(true); + expect(rect).toEqual(widgetRect); + }); + + it('adds placeholder to PDFKit document when AcroForm is already there', () => { + const {pdf} = createPdfkitDocument(PDFDocument, {}); + const form = pdf.ref({ + Type: 'AcroForm', + SigFlags: SIG_FLAGS.SIGNATURES_EXIST | SIG_FLAGS.APPEND_ONLY, + Fields: [], + }); + // eslint-disable-next-line no-underscore-dangle + pdf._root.data.AcroForm = form; + + const refs = pdfkitAddPlaceholder({ + ...defaults, + pdf, + pdfBuffer: Buffer.from([pdf]), + }); + expect(Object.keys(refs)).toEqual(expect.arrayContaining([ + 'signature', + 'form', + 'widget', + ])); + expect(pdf.page.dictionary.data.Annots).toHaveLength(1); + expect(pdf.page.dictionary.data.Annots[0].data.Subtype).toEqual('Widget'); + expect(pdf.page.dictionary.data.Annots[0].data.V.data.ByteRange).toEqual([ + 0, + '**********', + '**********', + '**********', + ]); + }); +}); diff --git a/packages/placeholder-pdfkit/tsconfig.json b/packages/placeholder-pdfkit/tsconfig.json new file mode 100644 index 00000000..503ffec7 --- /dev/null +++ b/packages/placeholder-pdfkit/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.*"] +}