From 5904f6c5a878463ce10cf3b4c2e1f26b6f4e1cbc Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 25 Sep 2023 15:57:10 +1000 Subject: [PATCH] chore: sign document --- apps/marketing/example/cert.p12 | Bin 0 -> 2637 bytes apps/web/example/cert.p12 | Bin 0 -> 2637 bytes package-lock.json | 46 +++++++++++ packages/lib/package.json | 3 +- .../lib/server-only/document/seal-document.ts | 5 +- .../signing/helpers/addSigningPlaceholder.ts | 73 ++++++++++++++++++ packages/signing/index.ts | 17 ++++ packages/signing/package.json | 23 ++++++ packages/signing/transports/local-cert.ts | 32 ++++++++ packages/signing/tsconfig.json | 8 ++ packages/tsconfig/process-env.d.ts | 6 ++ turbo.json | 7 +- 12 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 apps/marketing/example/cert.p12 create mode 100644 apps/web/example/cert.p12 create mode 100644 packages/signing/helpers/addSigningPlaceholder.ts create mode 100644 packages/signing/index.ts create mode 100644 packages/signing/package.json create mode 100644 packages/signing/transports/local-cert.ts create mode 100644 packages/signing/tsconfig.json diff --git a/apps/marketing/example/cert.p12 b/apps/marketing/example/cert.p12 new file mode 100644 index 0000000000000000000000000000000000000000..532ee19abea35f767bc0fa2cdcfa81f8f5a71d9b GIT binary patch literal 2637 zcmV-T3bOSuf(l6j0Ru3C3J(SeDuzgg_YDCD0ic2k00e>w{4jzE_%MP5uLcP!hDe6@ z4FLxRpn?RTFoFb}0s#Opf&`TY2`Yw2hW8Bt2LUh~1_~;MNQUg5g;9>wxv`8^#sE}apPVlOwcRA7bb4Pvyw zY@Z*4U#SGhAzg^p=(6+0gBgI$7_5kQYD~N&*x>2`7+chb#^ORwP7%$;-YK=nfS4n> zGieP!XPKpq^;pq0&}05OD?v7B678OMPB8v_8YzDWznc9kem72Dgd2i> zJ~LH2m*%O0e}c3xy8N0(C)YiTbUntum)ec7hhv6bAK>h(O#F=*Y;@2}75HvG&Gl)T zVabc7xX*uS%Zyh_;qbTcJm=sr3>V5@Z<xng(MjcDhRP@`;~=rvpvy;H7*E zWJ3m~ac9#%%v!yaf`|ZfA-@c*eW_IvzU^$8nYK-+_#r9UZAFq8`s+>b)HatxFdroC zKO2r`_~d5f?jLL|))^ikkZoY&KZ`)b416I-Zr6wVTDtkZL%Txfvmq97cmU4@ioKTj zTY~{LY!0Dt6n~7A6IY-2!2NUxU4?;ine{s1)K@Oswdzf$u1DS&M{yu1;#LaBt^AF* zxgXCKPM(w6HX^_c!>w7mb{siBM95m_gyBz_Fd$6>%D25~j#ST5-=4wMN*n}@8(XHP zUTM_v)dKt;5xRf;y4-x)r-B`N%PS1?iO-*hsRtmJ!qJ?ReVF;{rlm20{9Gl>wv5lW1vW`KuL7f_DM;u)T zIbTeUJ1654u;Ibh>*;kz9t+-n_TRI;S28ultx_*dmDj@|*`{8X;r93#UBiZfIvp;W zKM8meF~GpEre$>_QcPAL^oObo&leE?alqNxoC+<|IGm;H9Oy&!l|p61wk&cyASm~e zne)6?$A;Kq&$c!+&FCleBaZ(%>><9Nyw>dgCmYTl!(X`aYM)xL=s5&LNQUFES=38@Xnk3B0lc8%CJ*_zbI=CGTvJ}&NfP?M0UcRHIhUYKx*$pHd(6Z zN^oQ&+Ff|6~e!TViD1>lI)$ zv4Zfr_tkwp&)T}(CvygCEzrvLPvQi`S+`hxRv0b}2s3B!HmEQiJ0|OQZSj~0O*DcC zw)@ZlTQ7)Pd5|D~F#*^NdW}8w;7(8%ycCsNF-Ok$gPQ+3t-C8<5XXgj@CKu%#Buo_ z9O#WxJL!TJ|HT2UGE7Tei~8BIkz2L%$ZT2XycAq zb*FtSgvTE+pB6vgq4AeVEq9B}g95oImQMVOrE#$UrdKrN2dT9yQMyp=9KEat-*D2a z;C_7q0mbLQadgburNA^{7!A|;Ha@oFCf86BL10V7A;v)gB}gw?w*vBkeJl6E(mjiK z*MXYWb{;wT8eHJf6t^|&_vIKuaqYhITV#7xKh=@cX&y3v`kO;brME#K(XjD+AGcxr z)w8raTh%7T0-T384K(uwTQ8`>Yy_S;#Rrw4Ve$&_5m9H@#&GA^e0&vynJYKo?DF!8?l_MG` zTsE9VDLg1`X08O!j2SQxHvKa#L)|@&lX;esD^yk14{}}l%S!c)myvl8oGMLv$|9zD zX-?}@qS`CY`hK=5oSdS`v27L^Tb7X@7omUhZw2L}95A?2Uoh%fRlm8-l@L79Xw}IC4$CCn{K=1cCNpZHcO<6tNPK5EeJ$;R7%EK6%dx5X8CDBdWAb&@8l{|@|2~wQ z<&906xrjPI=Uza%(ao3`D1_=D_f1AepYos@gUMis%F2xWGX704fZjQ_s)AmyPopNU z56Ig8(bxa=kz2z=UgO4yn6!!+XZVH8ai{_bN?ojFe`_k;5Ox!@iI?%z-z8F)sCC6h z-Hg+Z$BZ#0Fe3&DDuzgg_YDCF6)_eB6o;hqW$WqLgxs?}etAdy2>#T_J}@ybAutIB v1uG5%0vZJX1Qd(@p-5Ut^*+VAPJv0L;H?OjlQjeg4-dtrpNk@=0s;sC;D+Q! literal 0 HcmV?d00001 diff --git a/apps/web/example/cert.p12 b/apps/web/example/cert.p12 new file mode 100644 index 0000000000000000000000000000000000000000..532ee19abea35f767bc0fa2cdcfa81f8f5a71d9b GIT binary patch literal 2637 zcmV-T3bOSuf(l6j0Ru3C3J(SeDuzgg_YDCD0ic2k00e>w{4jzE_%MP5uLcP!hDe6@ z4FLxRpn?RTFoFb}0s#Opf&`TY2`Yw2hW8Bt2LUh~1_~;MNQUg5g;9>wxv`8^#sE}apPVlOwcRA7bb4Pvyw zY@Z*4U#SGhAzg^p=(6+0gBgI$7_5kQYD~N&*x>2`7+chb#^ORwP7%$;-YK=nfS4n> zGieP!XPKpq^;pq0&}05OD?v7B678OMPB8v_8YzDWznc9kem72Dgd2i> zJ~LH2m*%O0e}c3xy8N0(C)YiTbUntum)ec7hhv6bAK>h(O#F=*Y;@2}75HvG&Gl)T zVabc7xX*uS%Zyh_;qbTcJm=sr3>V5@Z<xng(MjcDhRP@`;~=rvpvy;H7*E zWJ3m~ac9#%%v!yaf`|ZfA-@c*eW_IvzU^$8nYK-+_#r9UZAFq8`s+>b)HatxFdroC zKO2r`_~d5f?jLL|))^ikkZoY&KZ`)b416I-Zr6wVTDtkZL%Txfvmq97cmU4@ioKTj zTY~{LY!0Dt6n~7A6IY-2!2NUxU4?;ine{s1)K@Oswdzf$u1DS&M{yu1;#LaBt^AF* zxgXCKPM(w6HX^_c!>w7mb{siBM95m_gyBz_Fd$6>%D25~j#ST5-=4wMN*n}@8(XHP zUTM_v)dKt;5xRf;y4-x)r-B`N%PS1?iO-*hsRtmJ!qJ?ReVF;{rlm20{9Gl>wv5lW1vW`KuL7f_DM;u)T zIbTeUJ1654u;Ibh>*;kz9t+-n_TRI;S28ultx_*dmDj@|*`{8X;r93#UBiZfIvp;W zKM8meF~GpEre$>_QcPAL^oObo&leE?alqNxoC+<|IGm;H9Oy&!l|p61wk&cyASm~e zne)6?$A;Kq&$c!+&FCleBaZ(%>><9Nyw>dgCmYTl!(X`aYM)xL=s5&LNQUFES=38@Xnk3B0lc8%CJ*_zbI=CGTvJ}&NfP?M0UcRHIhUYKx*$pHd(6Z zN^oQ&+Ff|6~e!TViD1>lI)$ zv4Zfr_tkwp&)T}(CvygCEzrvLPvQi`S+`hxRv0b}2s3B!HmEQiJ0|OQZSj~0O*DcC zw)@ZlTQ7)Pd5|D~F#*^NdW}8w;7(8%ycCsNF-Ok$gPQ+3t-C8<5XXgj@CKu%#Buo_ z9O#WxJL!TJ|HT2UGE7Tei~8BIkz2L%$ZT2XycAq zb*FtSgvTE+pB6vgq4AeVEq9B}g95oImQMVOrE#$UrdKrN2dT9yQMyp=9KEat-*D2a z;C_7q0mbLQadgburNA^{7!A|;Ha@oFCf86BL10V7A;v)gB}gw?w*vBkeJl6E(mjiK z*MXYWb{;wT8eHJf6t^|&_vIKuaqYhITV#7xKh=@cX&y3v`kO;brME#K(XjD+AGcxr z)w8raTh%7T0-T384K(uwTQ8`>Yy_S;#Rrw4Ve$&_5m9H@#&GA^e0&vynJYKo?DF!8?l_MG` zTsE9VDLg1`X08O!j2SQxHvKa#L)|@&lX;esD^yk14{}}l%S!c)myvl8oGMLv$|9zD zX-?}@qS`CY`hK=5oSdS`v27L^Tb7X@7omUhZw2L}95A?2Uoh%fRlm8-l@L79Xw}IC4$CCn{K=1cCNpZHcO<6tNPK5EeJ$;R7%EK6%dx5X8CDBdWAb&@8l{|@|2~wQ z<&906xrjPI=Uza%(ao3`D1_=D_f1AepYos@gUMis%F2xWGX704fZjQ_s)AmyPopNU z56Ig8(bxa=kz2z=UgO4yn6!!+XZVH8ai{_bN?ojFe`_k;5Ox!@iI?%z-z8F)sCC6h z-Hg+Z$BZ#0Fe3&DDuzgg_YDCF6)_eB6o;hqW$WqLgxs?}etAdy2>#T_J}@ybAutIB v1uG5%0vZJX1Qd(@p-5Ut^*+VAPJv0L;H?OjlQjeg4-dtrpNk@=0s;sC;D+Q! literal 0 HcmV?d00001 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 +}