diff --git a/apps/marketing/example/cert.p12 b/apps/marketing/example/cert.p12 new file mode 100644 index 000000000..532ee19ab Binary files /dev/null and b/apps/marketing/example/cert.p12 differ diff --git a/apps/web/example/cert.p12 b/apps/web/example/cert.p12 new file mode 100644 index 000000000..532ee19ab Binary files /dev/null and b/apps/web/example/cert.p12 differ diff --git a/package-lock.json b/package-lock.json index b3ee1e7d4..1fde4f692 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1877,6 +1877,10 @@ "resolved": "packages/prisma", "link": true }, + "node_modules/@documenso/signing": { + "resolved": "packages/signing", + "link": true + }, "node_modules/@documenso/tailwind-config": { "resolved": "packages/tailwind-config", "link": true @@ -6542,6 +6546,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.0.tgz", "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==" }, + "node_modules/@types/node-forge": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.4.tgz", + "integrity": "sha512-08scBQriFsBbm/CuBWOXRMD1RG7ydFW01EDR6vGX27nxcj6E/jGSCOLdICNd8ETwQlLFXVBVA854RX6Y7vPSrQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz", @@ -14544,11 +14557,30 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==" }, + "node_modules/node-signpdf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-signpdf/-/node-signpdf-2.0.0.tgz", + "integrity": "sha512-B6fDvD8z2v0pRntQjgPO2ARqxh0pfNwfSn6YEbP7cv6xmPgcphFwcrMA3N47LztmpVbAM3vUFUslX32L6NRYDg==", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "node-forge": "^1.2.1" + } + }, "node_modules/node-sql-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-4.9.0.tgz", @@ -20047,6 +20079,20 @@ "typescript": "^5.1.6" } }, + "packages/signing": { + "name": "@documenso/signing", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@documenso/tsconfig": "*", + "node-forge": "^1.3.1", + "node-signpdf": "^2.0.0", + "pdf-lib": "^1.17.1" + }, + "devDependencies": { + "@types/node-forge": "^1.3.4" + } + }, "packages/tailwind-config": { "name": "@documenso/tailwind-config", "version": "0.0.0", diff --git a/packages/lib/package.json b/packages/lib/package.json index 5376acf13..381dcc1c6 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -19,6 +19,7 @@ "@aws-sdk/signature-v4-crt": "^3.410.0", "@documenso/email": "*", "@documenso/prisma": "*", + "@documenso/signing": "*", "@next-auth/prisma-adapter": "1.0.7", "@pdf-lib/fontkit": "^1.1.1", "@scure/base": "^1.1.3", @@ -38,4 +39,4 @@ "@types/bcrypt": "^5.0.0", "@types/luxon": "^3.3.1" } -} \ No newline at end of file +} diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index d551e4adf..179f33fb3 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -5,6 +5,7 @@ import { PDFDocument } from 'pdf-lib'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { signPdf } from '@documenso/signing'; import { getFile } from '../../universal/upload/get-file'; import { putFile } from '../../universal/upload/put-file'; @@ -71,12 +72,14 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => { const pdfBytes = await doc.save(); + const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) }); + const { name, ext } = path.parse(document.title); const { data: newData } = await putFile({ name: `${name}_signed${ext}`, type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)), + arrayBuffer: async () => Promise.resolve(pdfBuffer), }); await prisma.documentData.update({ diff --git a/packages/signing/helpers/addSigningPlaceholder.ts b/packages/signing/helpers/addSigningPlaceholder.ts new file mode 100644 index 000000000..6c7bb18e3 --- /dev/null +++ b/packages/signing/helpers/addSigningPlaceholder.ts @@ -0,0 +1,73 @@ +import signer from 'node-signpdf'; +import { PDFArray, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFString } from 'pdf-lib'; + +export type AddSigningPlaceholderOptions = { + pdf: Buffer; +}; + +export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => { + const doc = await PDFDocument.load(pdf); + const pages = doc.getPages(); + + const byteRange = PDFArray.withContext(doc.context); + + byteRange.push(PDFNumber.of(0)); + byteRange.push(PDFName.of(signer.byteRangePlaceholder)); + byteRange.push(PDFName.of(signer.byteRangePlaceholder)); + byteRange.push(PDFName.of(signer.byteRangePlaceholder)); + + const signature = doc.context.obj({ + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: 'adbe.pkcs7.detached', + ByteRange: byteRange, + Contents: PDFHexString.fromText(' '.repeat(8192)), + Reason: PDFString.of('Signed by Documenso'), + M: PDFString.fromDate(new Date()), + }); + + const signatureRef = doc.context.register(signature); + + const widget = doc.context.obj({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Sig', + Rect: [0, 0, 0, 0], + V: signatureRef, + T: PDFString.of('Signature1'), + F: 4, + P: pages[0].ref, + }); + + const widgetRef = doc.context.register(widget); + + let widgets = pages[0].node.get(PDFName.of('Annots')); + + if (widgets instanceof PDFArray) { + widgets.push(widgetRef); + } else { + const newWidgets = PDFArray.withContext(doc.context); + + newWidgets.push(widgetRef); + + pages[0].node.set(PDFName.of('Annots'), newWidgets); + + widgets = pages[0].node.get(PDFName.of('Annots')); + } + + if (!widgets) { + throw new Error('No widgets'); + } + + pages[0].node.set(PDFName.of('Annots'), widgets); + + doc.catalog.set( + PDFName.of('AcroForm'), + doc.context.obj({ + SigFlags: 3, + Fields: [widgetRef], + }), + ); + + return Buffer.from(await doc.save({ useObjectStreams: false })); +}; diff --git a/packages/signing/index.ts b/packages/signing/index.ts new file mode 100644 index 000000000..06e71761f --- /dev/null +++ b/packages/signing/index.ts @@ -0,0 +1,17 @@ +import { match } from 'ts-pattern'; + +import { signWithLocalCert } from './transports/local-cert'; + +export type SignOptions = { + pdf: Buffer; +}; + +export const signPdf = async ({ pdf }: SignOptions) => { + const transport = process.env.NEXT_PRIVATE_SIGNING_TRANSPORT || 'local'; + + return await match(transport) + .with('local', async () => signWithLocalCert({ pdf })) + .otherwise(() => { + throw new Error(`Unsupported signing transport: ${transport}`); + }); +}; diff --git a/packages/signing/package.json b/packages/signing/package.json new file mode 100644 index 000000000..3eaab8a05 --- /dev/null +++ b/packages/signing/package.json @@ -0,0 +1,23 @@ +{ + "name": "@documenso/signing", + "version": "1.0.0", + "main": "./index.ts", + "types": "./index.ts", + "license": "AGPLv3", + "files": [ + "transports/", + "index.ts" + ], + "scripts": { + }, + "dependencies": { + "@documenso/tsconfig": "*", + "node-forge": "^1.3.1", + "node-signpdf": "^2.0.0", + "pdf-lib": "^1.17.1", + "ts-pattern": "^5.0.5" + }, + "devDependencies": { + "@types/node-forge": "^1.3.4" + } +} diff --git a/packages/signing/transports/local-cert.ts b/packages/signing/transports/local-cert.ts new file mode 100644 index 000000000..435d9ef4d --- /dev/null +++ b/packages/signing/transports/local-cert.ts @@ -0,0 +1,32 @@ +import signer from 'node-signpdf'; +import fs from 'node:fs'; + +import { addSigningPlaceholder } from '../helpers/addSigningPlaceholder'; + +export type SignWithLocalCertOptions = { + pdf: Buffer; +}; + +export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => { + const pdfWithPlaceholder = await addSigningPlaceholder({ pdf }); + + let p12Cert: Buffer | null = null; + + if (process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS) { + p12Cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64'); + } + + if (!p12Cert) { + p12Cert = Buffer.from( + fs.readFileSync(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH || './example/cert.p12'), + ); + } + + if (process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE) { + return signer.sign(pdfWithPlaceholder, p12Cert, { + passphrase: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE, + }); + } + + return signer.sign(pdfWithPlaceholder, p12Cert); +}; diff --git a/packages/signing/tsconfig.json b/packages/signing/tsconfig.json new file mode 100644 index 000000000..e4f3a4a6b --- /dev/null +++ b/packages/signing/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@documenso/tsconfig/react-library.json", + "compilerOptions": { + "types": ["@documenso/tsconfig/process-env.d.ts", "@types/node"] + }, + "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index d32b9984a..86c5c7f64 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -21,6 +21,12 @@ declare namespace NodeJS { NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID?: string; NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY?: string; + NEXT_PRIVATE_SIGNING_TRANSPORT?: 'local' | 'http' | 'gcloud-hsm'; + NEXT_PRIVATE_SIGNING_PASSPHRASE?: string; + NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?: string; + NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string; + NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string; + NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api'; NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string; diff --git a/turbo.json b/turbo.json index c1e2c30c0..476efbd2d 100644 --- a/turbo.json +++ b/turbo.json @@ -44,6 +44,11 @@ "NEXT_PRIVATE_UPLOAD_BUCKET", "NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID", "NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY", + "NEXT_PRIVATE_SIGNING_TRANSPORT", + "NEXT_PRIVATE_SIGNING_PASSPHRASE", + "NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH", + "NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS", + "NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING", "NEXT_PRIVATE_SMTP_TRANSPORT", "NEXT_PRIVATE_MAILCHANNELS_API_KEY", "NEXT_PRIVATE_MAILCHANNELS_ENDPOINT", @@ -69,4 +74,4 @@ "POSTGRES_PRISMA_URL", "POSTGRES_URL_NON_POOLING" ] -} \ No newline at end of file +}