diff --git a/.github/workflows/monorepo.yml b/.github/workflows/monorepo.yml index 79869dbf..e22f94c7 100644 --- a/.github/workflows/monorepo.yml +++ b/.github/workflows/monorepo.yml @@ -43,6 +43,14 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} base-path: packages/placeholder-plain path-to-lcov: packages/placeholder-plain/coverage/lcov.info + - name: Coveralls (placeholder-pdf-lib) + uses: coverallsapp/github-action@master + with: + parallel: true + flag-name: placeholder-pdf-lib + github-token: ${{ secrets.GITHUB_TOKEN }} + base-path: packages/placeholder-pdf-lib + path-to-lcov: packages/placeholder-pdf-lib/coverage/lcov.info - name: Close Coveralls uses: coverallsapp/github-action@master with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d3099a..a48819c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ * [placeholder-pdfkit010] Uses SIG_FLAGS and ANNOTATION_FLAGS instead of magic numbers; * [placeholder-pdfkit010] Allow passing in widgetRect to override the default [0, 0, 0, 0] one; * [placeholder-plain] Allow passing in widgetRect to override the default [0, 0, 0, 0] one; +* [placeholder-pdf-lib] Introduce the package that uses PDF-LIB for adding a placeholder; * [signpdf] Use the BR position findByteRange provides to spare a search for it; * [examples] Introduce [an example that provides a visible widget](packages/examples/src/pdfkit010-with-visual.js) (implemented with pdfkit); +* [examples] Introduce [a `placeholder-pdf-lib` example](packages/examples/src/pdf-lib.js); * Bumped version of axios; ## [3.0.0] diff --git a/README.md b/README.md index 6f454969..8d255b8f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Depending on your usecase you may need different combinations of packages. ### I am getting PDFs that already have placeholders -This is the most simple case of them all. `$ npm i -S @signpdf/signpdf @signpdf/signer-p12 node-forge`. Then have a look at the [with-placeholder.js example](/packages/examples/with-placeholder.js). It should be as simple as: +This is the most simple case of them all. `$ npm i -S @signpdf/signpdf @signpdf/signer-p12 node-forge`. Then have a look at the [with-placeholder.js example](/packages/examples/src/with-placeholder.js). It should be as simple as: ```javascript import signpdf from '@signpdf/signpdf'; @@ -43,11 +43,19 @@ const signedPdf = await signpdf.sign(fs.readFileSync(PATH_TO_PDF_FILE), signer); ### I am generating a PDF with PDFKit -This is how the library was started as we needed to sign a document that we were generating on the fly. You will need `$ npm i -S @signpdf/signpdf @signpdf/placeholder-pdfkit010 @signpdf/signer-p12 node-forge` and a look at the [pdfkit010.js example](/packages/examples/pdfkit010.js). +This is how the library was started as we needed to sign a document that we were generating on the fly. You will need `$ npm i -S @signpdf/signpdf @signpdf/placeholder-pdfkit010 @signpdf/signer-p12 node-forge` and a look at the [pdfkit010.js example](/packages/examples/src/pdfkit010.js). ### I have a .pdf file and I want to sign it -This seems to be the most common usecase - people work with PDF documents coming from different sources and they need to digitally sign them. The [placeholder-plain](#placeholder-plain) helper can help here. Start with `$ npm i -S @signpdf/signpdf @signpdf/placeholder-plain @signpdf/signer-p12 node-forge`. Head over to either [the JS example](/packages/examples/javascript.js) or [the TS one](/packages/examples/typescript.ts). And note that the process may look simple on the surface but it is very fragile inside. Should you need some help go stright to [our GitHub Issues](https://github.com/vbuch/node-signpdf/issues?q=is%3Aissue). +This seems to be the most common usecase - people work with PDF documents coming from different sources and they need to digitally sign them. Both [placeholder helpers](#placeholder-helpers) placeholder-plain and placeholder-pdf-lib can help here. + +#### Plain + +Start with `$ npm i -S @signpdf/signpdf @signpdf/placeholder-plain @signpdf/signer-p12 node-forge`. Head over to either [the JS example](/packages/examples/src/javascript.js) or [the TS one](/packages/examples/src/typescript.ts). An advantage of working with the plain version would be that in theory it should be quicker and use less memory (not benchmarked). A great disadvantage: it is very fragile relying on strings being poisitioned in a certain way. + +#### PDF-LIB + +`$ npm i -S @signpdf/signpdf @signpdf/placeholder-pdf-lib pdf-lib @signpdf/signer-p12 node-forge` gets you started. Then comes the [the PDF-LIB example](/packages/examples/src/pdf-lib.js). PDF-LIB provides tremendous PDF API, it is very well documented and well supported. ## Packages @@ -85,6 +93,12 @@ Works on top of `PDFKit 0.10.0` and given a `PDFDocument` that is in the works ( Uses the process and knowledge from `placeholder-pdfkit010` on how to add e-signature placeholder but implements it with plain string operations (`.indexOf()`, `.replace()`, `.match()`, etc.). Because of the lack of semantics it is rather *fragile*. Additionally it doesn't support streams and only works on PDF version <= 1.3. Regardless of those disadvantages this helper seems to be the most popular among the users of `@signpdf`. When the placeholder is in place `@signpdf/signpdf` can complete the process. +#### [@signpdf/placeholder-pdf-lib](/packages/placeholder-pdf-lib) + +[![npm version](https://badge.fury.io/js/@signpdf%2Fplaceholder-pdf-lib.svg)](https://badge.fury.io/js/@signpdf%2Fplaceholder-pdf-lib) + +Works with PDF-LIB and given a loaded `PDFDocument`, adds an e-signature placeholder. When the placeholder is in place `@signpdf/signpdf` can complete the process. + ## Notes * The process of signing a document is described in the [Digital Signatures in PDF](https://www.adobe.com/devnet-docs/etk_deprecated/tools/DigSig/Acrobat_DigitalSignatures_in_PDF.pdf) document. As Adobe's files are deprecated, [here is the standard as defined by ETSI](). @@ -101,16 +115,15 @@ We have examples of PDFKit generation of documents and we also have some where a What's needed is a `Sig` element and a `Widget` that is also linked in a `Form`. The form needs to be referenced in the `Root` descriptor of the PDF as well. A (hopefully) [readable sample](/packages/placeholder-pdfkit010/src/pdfkitAddPlaceholder.js) is available in the helpers. Note the `Contents` descriptor of the `Sig` where zeros are placed that will later be replaced with the actual signature. -We provides two helpers for adding the signature placeholder: +We provides [placeholder helpers](#placeholder-helpers) that do that. -* [`@signpdf/placeholder-pdfkit010`](#placeholder-pdfkit010) -* [`@signpdf/placeholder-plain`](#placeholder-plain) +#### Signature length **Note:** 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`. +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 placeholder helpers. ```js import { pdfkitAddPlaceholder } from '@signpdf/placeholder-pdfkit010'; diff --git a/packages/examples/src/pdf-lib.js b/packages/examples/src/pdf-lib.js new file mode 100644 index 00000000..7b0e0d24 --- /dev/null +++ b/packages/examples/src/pdf-lib.js @@ -0,0 +1,45 @@ +var fs = require('fs'); +var path = require('path'); +var PDFDocument = require('pdf-lib').PDFDocument; +var pdflibAddPlaceholder = require('@signpdf/placeholder-pdf-lib').pdflibAddPlaceholder; +var signpdf = require('@signpdf/signpdf').default; +var P12Signer = require('@signpdf/signer-p12').P12Signer; + +function work() { + // contributing.pdf is the file that is going to be signed + var sourcePath = path.join(__dirname, '/../../../resources/contributing.pdf'); + var pdfBuffer = fs.readFileSync(sourcePath); + + // certificate.p12 is the certificate that is going to be used to sign + var certificatePath = path.join(__dirname, '/../../../resources/certificate.p12'); + var certificateBuffer = fs.readFileSync(certificatePath); + var signer = new P12Signer(certificateBuffer); + + // Load the document into PDF-LIB + PDFDocument.load(pdfBuffer).then(function (pdfDoc) { + // Add a placeholder for a signature. + pdflibAddPlaceholder({ + pdfDoc: pdfDoc, + reason: 'The user is decalaring consent through JavaScript.', + contactInfo: 'signpdf@example.com', + name: 'John Doe', + location: 'Free Text Str., Free World', + }); + + // Convert the PDF-LIB PDFDocument to Buffer + pdfDoc.save({useObjectStreams: false}).then(function (pdfBytes) { + var pdfWithPlaceholder = Buffer.from(pdfBytes); + + // And finally sign the document. + signpdf + .sign(pdfWithPlaceholder, signer) + .then(function (signedPdf) { + // signedPdf is a Buffer of an electronically signed PDF. Store it. + var targetPath = path.join(__dirname, '/../output/pdf-lib.pdf'); + fs.writeFileSync(targetPath, signedPdf); + }) + }) + }) +} + +work(); \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/.babelrc b/packages/placeholder-pdf-lib/.babelrc new file mode 100644 index 00000000..00bdc749 --- /dev/null +++ b/packages/placeholder-pdf-lib/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../babel.config.json" +} \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/.eslintrc b/packages/placeholder-pdf-lib/.eslintrc new file mode 100644 index 00000000..cdabc744 --- /dev/null +++ b/packages/placeholder-pdf-lib/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "@signpdf/eslint-config" + ] +} \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/README.md b/packages/placeholder-pdf-lib/README.md new file mode 100644 index 00000000..0d1383e4 --- /dev/null +++ b/packages/placeholder-pdf-lib/README.md @@ -0,0 +1,38 @@ +# Placehodler providing helper using PDF-LIB + +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-pdf-lib.svg)](https://badge.fury.io/js/@signpdf%2Fplaceholder-pdf-lib) +[![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 with `PDF-LIB` and given a PDFDocument that is in the works, adds an e-signature placeholder. When the PDF is ready you can convert it to Buffer and pass it to `@signpdf/signpdf` to complete the process. + +## Usage + +You will need `$ npm i -S @signpdf/placeholder-pdf-lib pdf-lib @signpdf/signpdf node-forge` and a look at the [pdf-lib.js example](/packages/examples/pdf-lib.js). + +## 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. + +```js +import { pdflibAddPlaceholder } from '@signpdf/placeholder-pdf-lib'; +import { SUBFILTER_ETSI_CADES_DETACHED } from '@signpdf/utils'; + +pdflibAddPlaceholder({ + pdfDoc: pdfToSign, + ..., + subFilter: SUBFILTER_ETSI_CADES_DETACHED, +}); +``` diff --git a/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.d.ts b/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.d.ts new file mode 100644 index 00000000..3d3332b1 --- /dev/null +++ b/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.d.ts @@ -0,0 +1,20 @@ +export function pdflibAddPlaceholder({ pdfDoc, reason, contactInfo, name, location, signatureLength, byteRangePlaceholder, subFilter, widgetRect, }: InputType): void; +export type PDFDocument = import('pdf-lib').PDFDocument; +export type InputType = { + pdfDoc: PDFDocument; + 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[]; +}; +//# sourceMappingURL=pdflibAddPlaceholder.d.ts.map \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.d.ts.map b/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.d.ts.map new file mode 100644 index 00000000..1aac768f --- /dev/null +++ b/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"pdflibAddPlaceholder.d.ts","sourceRoot":"","sources":["../src/pdflibAddPlaceholder.js"],"names":[],"mappings":"AAoCO,qJAHI,SAAS,GACP,IAAI,CA0FhB;0BAhHY,OAAO,SAAS,EAAE,WAAW;;YAK7B,WAAW;YACX,MAAM;iBACN,MAAM;UACN,MAAM;cACN,MAAM;sBACN,MAAM;2BACN,MAAM;;;;gBACN,MAAM;;;;iBACN,MAAM,EAAE"} \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.js b/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.js new file mode 100644 index 00000000..f1bad163 --- /dev/null +++ b/packages/placeholder-pdf-lib/dist/pdflibAddPlaceholder.js @@ -0,0 +1,123 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.pdflibAddPlaceholder = void 0; +var _utils = require("@signpdf/utils"); +var _pdfLib = require("pdf-lib"); +/** + * @typedef {import('pdf-lib').PDFDocument} PDFDocument + */ + +/** +* @typedef {object} InputType +* @property {PDFDocument} pdfDoc +* @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 +*/ + +/** + * Adds a signature placeholder to a PDF-LIB PDFDocument. + * + * Alters the passed pdfDoc and returns void. + * + * @param {InputType} + * @returns {void} + */ +const pdflibAddPlaceholder = ({ + pdfDoc, + 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] +}) => { + const page = pdfDoc.getPage(0); + + // Create a placeholder where the the last 3 parameters of the + // actual range will be replaced when signing is done. + const byteRange = _pdfLib.PDFArray.withContext(pdfDoc.context); + byteRange.push(_pdfLib.PDFNumber.of(0)); + byteRange.push(_pdfLib.PDFName.of(byteRangePlaceholder)); + byteRange.push(_pdfLib.PDFName.of(byteRangePlaceholder)); + byteRange.push(_pdfLib.PDFName.of(byteRangePlaceholder)); + + // Fill the contents of the placeholder with 00s. + const placeholder = _pdfLib.PDFHexString.of(String.fromCharCode(0).repeat(signatureLength)); + + // Create a signature dictionary to be referenced in the signature widget. + const signatureDict = pdfDoc.context.obj({ + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: subFilter, + ByteRange: byteRange, + Contents: placeholder, + Reason: _pdfLib.PDFString.of(reason), + M: _pdfLib.PDFString.fromDate(new Date()), + ContactInfo: _pdfLib.PDFString.of(contactInfo), + Name: _pdfLib.PDFString.of(name), + Location: _pdfLib.PDFString.of(location) + }, pdfDoc.index); + const signatureDictRef = pdfDoc.context.register(signatureDict); + + // Create the signature widget + const rect = _pdfLib.PDFArray.withContext(pdfDoc.context); + widgetRect.forEach(c => rect.push(_pdfLib.PDFNumber.of(c))); + const widgetDict = pdfDoc.context.obj({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Sig', + Rect: rect, + V: signatureDictRef, + T: _pdfLib.PDFString.of('Signature1'), + F: _utils.ANNOTATION_FLAGS.PRINT, + P: page.ref + }, pdfDoc.index); + const widgetDictRef = pdfDoc.context.register(widgetDict); + + // Annotate the widget on the first page + let annotations = page.node.lookupMaybe(_pdfLib.PDFName.of('Annots'), _pdfLib.PDFArray); + if (typeof annotations === 'undefined') { + annotations = pdfDoc.context.obj([]); + } + annotations.push(widgetDictRef); + page.node.set(_pdfLib.PDFName.of('Annots'), annotations); + + // Add an AcroForm or update the existing one + let acroForm = pdfDoc.catalog.lookupMaybe(_pdfLib.PDFName.of('AcroForm'), _pdfLib.PDFDict); + if (typeof acroForm === 'undefined') { + // Need to create a new AcroForm + acroForm = pdfDoc.context.obj({ + Fields: [] + }); + const acroFormRef = pdfDoc.context.register(acroForm); + pdfDoc.catalog.set(_pdfLib.PDFName.of('AcroForm'), acroFormRef); + } + + /** + * @type {PDFNumber} + */ + let sigFlags; + if (acroForm.has(_pdfLib.PDFName.of('SigFlags'))) { + // Already has some flags, will merge + sigFlags = acroForm.get(_pdfLib.PDFName.of('SigFlags')); + } else { + // Create blank flags + sigFlags = _pdfLib.PDFNumber.of(0); + } + const updatedFlags = _pdfLib.PDFNumber.of(sigFlags.asNumber() | _utils.SIG_FLAGS.SIGNATURES_EXIST | _utils.SIG_FLAGS.APPEND_ONLY); + acroForm.set(_pdfLib.PDFName.of('SigFlags'), updatedFlags); + const fields = acroForm.get(_pdfLib.PDFName.of('Fields')); + fields.push(widgetDictRef); +}; +exports.pdflibAddPlaceholder = pdflibAddPlaceholder; \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/jest.config.js b/packages/placeholder-pdf-lib/jest.config.js new file mode 100644 index 00000000..8fe322bc --- /dev/null +++ b/packages/placeholder-pdf-lib/jest.config.js @@ -0,0 +1,4 @@ +const sharedConfig = require('../../jest.config.base'); +module.exports = { + ...sharedConfig, +}; \ No newline at end of file diff --git a/packages/placeholder-pdf-lib/package.json b/packages/placeholder-pdf-lib/package.json new file mode 100644 index 00000000..e5255a88 --- /dev/null +++ b/packages/placeholder-pdf-lib/package.json @@ -0,0 +1,72 @@ +{ + "name": "@signpdf/placeholder-pdf-lib", + "version": "3.0.0", + "description": "Use PDF-LIB 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/pdflibAddPlaceholder.js", + "types": "dist/pdflibAddPlaceholder.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": "*" + }, + "peerDependencies": { + "pdf-lib": "^1.17.1" + }, + "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", + "pdf-lib": "^1.17.1", + "typescript": "^5.2.2" + } +} diff --git a/packages/placeholder-pdf-lib/src/pdflibAddPlaceholder.js b/packages/placeholder-pdf-lib/src/pdflibAddPlaceholder.js new file mode 100644 index 00000000..67954aaf --- /dev/null +++ b/packages/placeholder-pdf-lib/src/pdflibAddPlaceholder.js @@ -0,0 +1,125 @@ +import { + ANNOTATION_FLAGS, + DEFAULT_BYTE_RANGE_PLACEHOLDER, + DEFAULT_SIGNATURE_LENGTH, + SIG_FLAGS, + SUBFILTER_ADOBE_PKCS7_DETACHED, +} from '@signpdf/utils'; +import { + PDFArray, PDFDict, PDFHexString, PDFName, PDFNumber, PDFString, +} from 'pdf-lib'; + +/** + * @typedef {import('pdf-lib').PDFDocument} PDFDocument + */ + +/** +* @typedef {object} InputType +* @property {PDFDocument} pdfDoc +* @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 +*/ + +/** + * Adds a signature placeholder to a PDF-LIB PDFDocument. + * + * Alters the passed pdfDoc and returns void. + * + * @param {InputType} + * @returns {void} + */ +export const pdflibAddPlaceholder = ({ + pdfDoc, + reason, + contactInfo, + name, + location, + signatureLength = DEFAULT_SIGNATURE_LENGTH, + byteRangePlaceholder = DEFAULT_BYTE_RANGE_PLACEHOLDER, + subFilter = SUBFILTER_ADOBE_PKCS7_DETACHED, + widgetRect = [0, 0, 0, 0], +}) => { + const page = pdfDoc.getPage(0); + + // Create a placeholder where the the last 3 parameters of the + // actual range will be replaced when signing is done. + const byteRange = PDFArray.withContext(pdfDoc.context); + byteRange.push(PDFNumber.of(0)); + byteRange.push(PDFName.of(byteRangePlaceholder)); + byteRange.push(PDFName.of(byteRangePlaceholder)); + byteRange.push(PDFName.of(byteRangePlaceholder)); + + // Fill the contents of the placeholder with 00s. + const placeholder = PDFHexString.of(String.fromCharCode(0).repeat(signatureLength)); + + // Create a signature dictionary to be referenced in the signature widget. + const signatureDict = pdfDoc.context.obj({ + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: subFilter, + ByteRange: byteRange, + Contents: placeholder, + Reason: PDFString.of(reason), + M: PDFString.fromDate(new Date()), + ContactInfo: PDFString.of(contactInfo), + Name: PDFString.of(name), + Location: PDFString.of(location), + }, pdfDoc.index); + const signatureDictRef = pdfDoc.context.register(signatureDict); + + // Create the signature widget + const rect = PDFArray.withContext(pdfDoc.context); + widgetRect.forEach((c) => rect.push(PDFNumber.of(c))); + const widgetDict = pdfDoc.context.obj({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Sig', + Rect: rect, + V: signatureDictRef, + T: PDFString.of('Signature1'), + F: ANNOTATION_FLAGS.PRINT, + P: page.ref, + }, pdfDoc.index); + const widgetDictRef = pdfDoc.context.register(widgetDict); + + // Annotate the widget on the first page + let annotations = page.node.lookupMaybe(PDFName.of('Annots'), PDFArray); + if (typeof annotations === 'undefined') { + annotations = pdfDoc.context.obj([]); + } + annotations.push(widgetDictRef); + page.node.set(PDFName.of('Annots'), annotations); + + // Add an AcroForm or update the existing one + let acroForm = pdfDoc.catalog.lookupMaybe(PDFName.of('AcroForm'), PDFDict); + if (typeof acroForm === 'undefined') { + // Need to create a new AcroForm + acroForm = pdfDoc.context.obj({Fields: []}); + const acroFormRef = pdfDoc.context.register(acroForm); + pdfDoc.catalog.set(PDFName.of('AcroForm'), acroFormRef); + } + + /** + * @type {PDFNumber} + */ + let sigFlags; + if (acroForm.has(PDFName.of('SigFlags'))) { + // Already has some flags, will merge + sigFlags = acroForm.get(PDFName.of('SigFlags')); + } else { + // Create blank flags + sigFlags = PDFNumber.of(0); + } + const updatedFlags = PDFNumber.of( + sigFlags.asNumber() | SIG_FLAGS.SIGNATURES_EXIST | SIG_FLAGS.APPEND_ONLY, + ); + acroForm.set(PDFName.of('SigFlags'), updatedFlags); + const fields = acroForm.get(PDFName.of('Fields')); + fields.push(widgetDictRef); +}; diff --git a/packages/placeholder-pdf-lib/src/pdflibAddPlaceholder.test.js b/packages/placeholder-pdf-lib/src/pdflibAddPlaceholder.test.js new file mode 100644 index 00000000..e686cda5 --- /dev/null +++ b/packages/placeholder-pdf-lib/src/pdflibAddPlaceholder.test.js @@ -0,0 +1,211 @@ +import { + PDFArray, PDFDict, PDFDocument, PDFName, PDFString, +} from 'pdf-lib'; +import {readTestResource} from '@signpdf/internal-utils'; +import {DEFAULT_BYTE_RANGE_PLACEHOLDER, SUBFILTER_ETSI_CADES_DETACHED} from '@signpdf/utils'; +import {pdflibAddPlaceholder} from './pdflibAddPlaceholder'; + +describe(pdflibAddPlaceholder, () => { + const defaults = { + reason: 'Because I can', + contactInfo: 'testemail@example.com', + name: 'test name', + location: 'test Location', + }; + + it('adds placeholder to a prepared document', async () => { + const input = readTestResource('w3dummy.pdf'); + expect(input.indexOf('/ByteRange')).toBe(-1); + const pdfDoc = await PDFDocument.load(input); + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + }); + // Convert the PDFDocument to bytes + const pdfBytes = await pdfDoc.save({useObjectStreams: false}); + // and then to buffer + const buffer = Buffer.from(pdfBytes); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.indexOf('/ByteRange')).not.toBe(-1); + expect(buffer.indexOf('/Subtype /Widget')).not.toBe(-1); + expect(buffer.indexOf('/Filter /Adobe.PPKLite')).not.toBe(-1); + }); + + it('allows defining signature SubFilter', async () => { + const input = readTestResource('w3dummy.pdf'); + expect(input.indexOf('/ByteRange')).toBe(-1); + const pdfDoc = await PDFDocument.load(input); + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + subFilter: SUBFILTER_ETSI_CADES_DETACHED, + }); + // Convert the PDFDocument to bytes + const pdfBytes = await pdfDoc.save({useObjectStreams: false}); + // and then to buffer + const buffer = Buffer.from(pdfBytes); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.indexOf(`/SubFilter /${SUBFILTER_ETSI_CADES_DETACHED}`)).not.toBe(-1); + }); + + it('placeholder contains reason, contactInfo, name, location', async () => { + const input = readTestResource('w3dummy.pdf'); + expect(input.indexOf('/ByteRange')).toBe(-1); + const pdfDoc = await PDFDocument.load(input); + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + }); + + /** + * @type {PDFArray} + */ + const annots = pdfDoc.getPage(0).node.lookup(PDFName.of('Annots')); + + /** + * @type {PDFDict} + */ + const widget = annots.lookup(annots.size() - 1, PDFDict); + + /** + * @type {PDFDict} + */ + const widgetData = widget.lookup(PDFName.of('V')); + + expect(widget.get(PDFName.of('Subtype'))).toEqual(PDFName.of('Widget')); + expect(widgetData.get(PDFName.of('Reason'))).toEqual(PDFString.of(defaults.reason)); + expect(widgetData.get(PDFName.of('ContactInfo'))).toEqual(PDFString.of(defaults.contactInfo)); + expect(widgetData.get(PDFName.of('Location'))).toEqual(PDFString.of(defaults.location)); + expect(widgetData.get(PDFName.of('Name'))).toEqual(PDFString.of(defaults.name)); + }); + + it('sets the widget rectange to invisible by default', async () => { + const input = readTestResource('w3dummy.pdf'); + expect(input.indexOf('/ByteRange')).toBe(-1); + const pdfDoc = await PDFDocument.load(input); + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + }); + + /** + * @type {PDFArray} + */ + const annots = pdfDoc.getPage(0).node.lookup(PDFName.of('Annots')); + + /** + * @type {PDFDict} + */ + const widget = annots.lookup(annots.size() - 1, PDFDict); + + /** + * @type {PDFArray} + */ + const rect = widget.get(PDFName.of('Rect')); + expect(rect).toBeInstanceOf(PDFArray); + expect(rect.toString()).toEqual('[ 0 0 0 0 ]'); + }); + + it('allows defining widget rectange', async () => { + const input = readTestResource('w3dummy.pdf'); + expect(input.indexOf('/ByteRange')).toBe(-1); + const pdfDoc = await PDFDocument.load(input); + const widgetRect = [100, 100, 200, 200]; + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + widgetRect, + }); + + /** + * @type {PDFArray} + */ + const annots = pdfDoc.getPage(0).node.lookup(PDFName.of('Annots')); + + /** + * @type {PDFDict} + */ + const widget = annots.lookup(annots.size() - 1, PDFDict); + + /** + * @type {PDFArray} + */ + const rect = widget.get(PDFName.of('Rect')); + expect(rect).toBeInstanceOf(PDFArray); + expect(rect.toString()).toEqual('[ 100 100 200 200 ]'); + }); + + it('does not overwrite the AcroForm when it was already there', async () => { + const input = readTestResource('signed-once.pdf'); + expect(input.indexOf('/ByteRange')).not.toBe(-1); + const pdfDoc = await PDFDocument.load(input); + const existingAcroFormTag = pdfDoc.catalog.get(PDFName.of('AcroForm')).tag; + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + }); + + const newAcroFormRef = pdfDoc.catalog.get(PDFName.of('AcroForm')); + expect(newAcroFormRef.tag).toBe(existingAcroFormTag); + }); + + it('does not overwrite page annotations when there already were some', async () => { + const input = readTestResource('signed-once.pdf'); + expect(input.indexOf('/ByteRange')).not.toBe(-1); + const pdfDoc = await PDFDocument.load(input); + const existingAnnotations = pdfDoc + .getPage(0).node + .lookup(PDFName.of('Annots'), PDFArray) + .asArray().map((v) => v.toString()); + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + }); + + const newAnnotations = pdfDoc + .getPage(0).node + .lookup(PDFName.of('Annots'), PDFArray) + .asArray().map((v) => v.toString()); + expect(newAnnotations).toEqual(expect.arrayContaining(existingAnnotations)); + expect(newAnnotations).toHaveLength(existingAnnotations.length + 1); + }); + + it('adds placeholder to PDFDocument document when AcroForm is already there', async () => { + const input = readTestResource('signed-once.pdf'); + expect(input.indexOf('/ByteRange')).not.toBe(-1); + const pdfDoc = await PDFDocument.load(input); + + pdflibAddPlaceholder({ + pdfDoc, + ...defaults, + }); + + const annotations = pdfDoc + .getPage(0).node + .lookup(PDFName.of('Annots'), PDFArray); + const widgetDict = annotations.lookup(annotations.size() - 1, PDFDict); + expect(widgetDict.get(PDFName.of('Subtype'))).toEqual(PDFName.of('Widget')); + + const byteRange = widgetDict + .lookup(PDFName.of('V'), PDFDict) + .lookup(PDFName.of('ByteRange'), PDFArray) + .asArray() + .map((v) => v.toString()); + + expect(byteRange).toEqual([ + '0', + PDFName.of(DEFAULT_BYTE_RANGE_PLACEHOLDER).asString(), + PDFName.of(DEFAULT_BYTE_RANGE_PLACEHOLDER).asString(), + PDFName.of(DEFAULT_BYTE_RANGE_PLACEHOLDER).asString(), + ]); + }); +}); diff --git a/packages/placeholder-pdf-lib/tsconfig.json b/packages/placeholder-pdf-lib/tsconfig.json new file mode 100644 index 00000000..49bf12f6 --- /dev/null +++ b/packages/placeholder-pdf-lib/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.*"] +} diff --git a/packages/placeholder-pdfkit010/src/pdfkitAddPlaceholder.test.js b/packages/placeholder-pdfkit010/src/pdfkitAddPlaceholder.test.js index fef1cfd5..7e5b60f6 100644 --- a/packages/placeholder-pdfkit010/src/pdfkitAddPlaceholder.test.js +++ b/packages/placeholder-pdfkit010/src/pdfkitAddPlaceholder.test.js @@ -36,29 +36,6 @@ describe(pdfkitAddPlaceholder, () => { it('placeholder contains reason, contactInfo, name, location', () => { const {pdf} = createPdfkitDocument(); - const refs = pdfkitAddPlaceholder({ - pdf, - pdfBuffer: Buffer.from([pdf]), - reason: 'test reason', - ...defaults, - }); - 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('(testemail@example.com)'); - expect(PDFObject.convert(widgetData.Name)).toEqual('(test name)'); - expect(PDFObject.convert(widgetData.Location)).toEqual('(test Location)'); - }); - - it('placeholder contains default values for contactInfo, name, location', () => { - const {pdf} = createPdfkitDocument(); - const refs = pdfkitAddPlaceholder({ ...defaults, pdf, diff --git a/yarn.lock b/yarn.lock index fea920eb..8c6d47a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1680,6 +1680,20 @@ node-addon-api "^3.2.1" node-gyp-build "^4.3.0" +"@pdf-lib/standard-fonts@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz#8ba691c4421f71662ed07c9a0294b44528af2d7f" + integrity sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA== + dependencies: + pako "^1.0.6" + +"@pdf-lib/upng@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@pdf-lib/upng/-/upng-1.0.1.tgz#7dc9c636271aca007a9df4deaf2dd7e7960280cb" + integrity sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ== + dependencies: + pako "^1.0.10" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -6617,6 +6631,11 @@ pako@^0.2.5: resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== +pako@^1.0.10, pako@^1.0.11, pako@^1.0.6: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -6711,6 +6730,16 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pdf-lib@^1.17.1: + version "1.17.1" + resolved "https://registry.yarnpkg.com/pdf-lib/-/pdf-lib-1.17.1.tgz#9e7dd21261a0c1fb17992580885b39e7d08f451f" + integrity sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw== + dependencies: + "@pdf-lib/standard-fonts" "^1.0.0" + "@pdf-lib/upng" "^1.0.1" + pako "^1.0.11" + tslib "^1.11.1" + pdfkit@~0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/pdfkit/-/pdfkit-0.10.0.tgz#88f2aa8e3cf9e1cc2caff6447b68dd4e435cb284" @@ -7867,7 +7896,7 @@ tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.8.1: +tslib@^1.11.1, tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==