From 1583e5f70f488cd572ad190c780197013f021aae Mon Sep 17 00:00:00 2001 From: zboris12 Date: Sat, 17 Sep 2022 21:55:09 +0900 Subject: [PATCH] The first commit. --- .gitignore | 2 + README.md | 108 ++++++++++- test.html | 77 ++++++++ zgapdfsigner.js | 507 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 test.html create mode 100644 zgapdfsigner.js diff --git a/.gitignore b/.gitignore index 6704566..4ad1f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +test + # Logs logs *.log diff --git a/README.md b/README.md index ff50ef9..9109c37 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,106 @@ -# zgapdfsigner -A javascript tool to sign a pdf in web browser. +# ZgaPdfSigner +A javascript tool to sign a pdf in web browser. + +PS: __ZGA__ is the abbreviation of my father's name. +And I use this name to hope the merits from this application will be dedicated to my parents. + +## The Dependencies + +* [pdf-lib](https://pdf-lib.js.org/) +* [node-forge](https://github.com/digitalbazaar/forge) + +## How to use this tool + +Just import the dependencies and this tool. +```html + + + +``` + +## Let's sign + +Sign with an invisible signature. + +```js +/** + * @param {ArrayBuffer} pdf + * @param {ArrayBuffer} cert + * @param {string} pwd + * @return {Blob} + */ +async function sign1(pdf, cert, pwd){ + /** @type {SignOption} */ + var sopt = { + p12cert: cert, + pwd: pwd, + }; + var signer = new PdfSigner(sopt); + return await signer.sign(pdf); +} +``` + +Sign with a visible signature of a picture. + +```js +/** + * @param {ArrayBuffer} pdf + * @param {ArrayBuffer} cert + * @param {string} pwd + * @param {ArrayBuffer} imgdat + * @param {string} imgtyp + * @return {Blob} + */ +async function sign2(pdf, cert, pwd, imgdat, imgtyp){ + /** @type {SignOption} */ + var sopt = { + p12cert: cert, + pwd: pwd, + drawinf: { + area: { + x: 25, // left + y: 150, // top + w: 60, // width + h: 60, // height + }, + imgData: imgdat, + imgType: imgtyp, + }, + }; + var signer = new PdfSigner(sopt); + return await signer.sign(pdf); +} +``` + +Sign with a visible signature of drawing a text. + +```js +//TODO +``` + +## Detail of SignOption + +* __p12cert__: string :point_right: A raw data of the certificate +* __pwd__: string :point_right: The passphrase of the certificate +* __reason__: string :point_right: (Optional) The reason for signing +* __location__: string :point_right: (Optional) Your location +* __contact__: string :point_right: (Optional) Your contact information +* __signdate__: Date :point_right: (Optional) The date and time of signing +* __signame__: string :point_right: (Optional) The name of the signature +* __drawinf__: SignDrawInfo :point_right: (Optional) Visible signature's information + * __area__: SignAreaInfo :point_right: The signature's drawing area + * __x__: number :point_right: Distance from left + * __y__: number :point_right: Distance from top + * __w__: number :point_right: Width + * __h__: number :point_right: Height + * __pageidx__: number :point_right: (Optional) The page index for drawing the signature + * __imgData__: Uint8Array|ArrayBuffer|string :point_right: (Optional) The image's data + * __imgType__: string :point_right: (Optional) The image's type, __only support jpg and png__ + * __text__: string :point_right: (Optional) A text drawing on signature, __not implemented yet__ + * __fontData__: PDFLib.StandardFonts|Uint8Array|ArrayBuffer|string :point_right: (Optional) The font's data for drawing text, __not implemented yet__ + + +## License + +This tool is available under the +[MIT license](https://opensource.org/licenses/MIT). diff --git a/test.html b/test.html new file mode 100644 index 0000000..aaecd2b --- /dev/null +++ b/test.html @@ -0,0 +1,77 @@ + + + + + + Test for ZgaPdfSigner + + + + + + +
+
+
+
+ + + + diff --git a/zgapdfsigner.js b/zgapdfsigner.js new file mode 100644 index 0000000..50c6272 --- /dev/null +++ b/zgapdfsigner.js @@ -0,0 +1,507 @@ +/** + * the base point of x, y is top left corner. + * @typedef + * {{ + * x: number, + * y: number, + * w: number, + * h: number, + * }} + */ +var SignAreaInfo; +/** + * @typedef + * {{ + * area: SignAreaInfo, + * pageidx: (number|undefined), + * imgData: (Uint8Array|ArrayBuffer|string|undefined), + * imgType: (string|undefined), + * text: (string|undefined), + * fontData: (PDFLib.StandardFonts|Uint8Array|ArrayBuffer|string|undefined), + * img: (PDFLib.PDFImage|undefined), + * font: (PDFLib.PDFFont|undefined), + * }} + */ +var SignDrawInfo; +/** + * @typedef + * {{ + * p12cert: string, + * pwd: string, + * reason: (string|undefined), + * location: (string|undefined), + * contact: (string|undefined), + * signdate: (Date|undefined), + * signame: (string|undefined), + * drawinf: (SignDrawInfo|undefined), + * }} + */ +var SignOption; + +class PdfSigner { + /** + * @constructor + * @param {SignOption} signopt + */ + constructor(signopt){ + /** @private @type {SignOption} */ + this.opt = signopt; + } + + /** + * @public + * @param {PDFLib.PDFDocument|Uint8Array|ArrayBuffer|string} pdf + * @return {Blob} + */ + async sign(pdf){ + /** @type {PDFLib.PDFDocument} */ + var pdfdoc = null; + if(pdf.addPage){ + pdfdoc = pdf; + }else{ + pdfdoc = await PDFLib.PDFDocument.load(pdf); + } + + if(this.opt.drawinf && this.opt.drawinf.imgData && !this.opt.drawinf.img){ + if(this.opt.drawinf.imgType == "png"){ + this.opt.drawinf.img = await pdfdoc.embedPng(this.opt.drawinf.imgData); + }else if(this.opt.drawinf.imgType == "jpg"){ + this.opt.drawinf.img = await pdfdoc.embedJpg(this.opt.drawinf.imgData); + }else{ + throw new Error("Unkown image type. " + this.opt.drawinf.imgType); + } + } + + this.addSignHolder(pdfdoc); + var uarr = await pdfdoc.save({"useObjectStreams": false}); + // return new Blob([uarr], {"type" : "application/pdf"}); + var pdfstr = String.fromCharCode.apply(null, uarr); + + return this.signPdf(pdfstr); + } + + /** + * @private + * @param {PDFLib.PDFDocument} pdfdoc + */ + addSignHolder(pdfdoc){ + /** @const {string} */ + const DEFAULT_BYTE_RANGE_PLACEHOLDER = "**********"; + /** @const {number} */ + const SIGNATURE_LENGTH = 3322; + + /** @const {VisualSignature} */ + const visign = new VisualSignature(this.opt.drawinf); + /** @const {PDFLib.PDFRef} */ + const strmRef = visign.createStream(pdfdoc, this.opt.signame); + /** @const {PDFLib.PDFPage} */ + const page = pdfdoc.getPages()[visign.getPageIndex()]; + + if(!this.opt.signdate){ + this.opt.signdate = new Date(); + } + + /** @type {PDFLib.PDFArray} */ + var bytrng = new PDFLib.PDFArray(pdfdoc.context); + bytrng.push(PDFLib.PDFNumber.of(0)); + bytrng.push(PDFLib.PDFName.of(DEFAULT_BYTE_RANGE_PLACEHOLDER)); + bytrng.push(PDFLib.PDFName.of(DEFAULT_BYTE_RANGE_PLACEHOLDER)); + bytrng.push(PDFLib.PDFName.of(DEFAULT_BYTE_RANGE_PLACEHOLDER)); + + /** @type {Object} */ + var signObj = { + "Type": "Sig", + "Filter": "Adobe.PPKLite", + "SubFilter": "adbe.pkcs7.detached", + "ByteRange": bytrng, + "Contents": PDFLib.PDFHexString.of("0".repeat(SIGNATURE_LENGTH)), + "M": PDFLib.PDFString.fromDate(this.opt.signdate), + "Prop_Build": pdfdoc.context.obj({ + "App": pdfdoc.context.obj({ + "Name": "ZbPdfSinger", + }), + }), + }; + if(this.opt.reason){ + signObj["Reason"] = PDFLib.PDFString.of(this.opt.reason); + } + if(this.opt.location){ + signObj["Location"] = PDFLib.PDFString.of(this.opt.location); + } + if(this.opt.contact){ + signObj["ContactInfo"] = PDFLib.PDFString.of(this.opt.contact); + } + var signatureDictRef = pdfdoc.context.register(pdfdoc.context.obj(signObj)); + + /** @type {Object} */ + var widgetObj = { + "Type": "Annot", + "Subtype": "Widget", + "FT": "Sig", + "Rect": visign.getSignRect(), + "V": signatureDictRef, + "T": PDFLib.PDFString.of(this.opt.signame ? this.opt.signame : "Signature1"), + "F": 132, + "P": page.ref, + }; + if(strmRef){ + widgetObj["AP"] = pdfdoc.context.obj({ + "N": strmRef, + }); + } + var widgetDictRef = pdfdoc.context.register(pdfdoc.context.obj(widgetObj)); + + // Add our signature widget to the page + page.node.set(PDFLib.PDFName.of("Annots"), pdfdoc.context.obj([widgetDictRef])); + + // Create an AcroForm object containing our signature widget + pdfdoc.catalog.set( + PDFLib.PDFName.of("AcroForm"), + pdfdoc.context.obj({ + "SigFlags": 3, + "Fields": [widgetDictRef], + }), + ); + } + + /** + * @private + * @param {string} pdfstr + * @return {Blob} + */ + signPdf(pdfstr){ + if(!this.opt.signdate){ + this.opt.signdate = new Date(); + } + + // Finds ByteRange information within a given PDF Buffer if one exists + var byteRangeStrings = pdfstr.match(/\/ByteRange\s*\[{1}\s*(?:(?:\d*|\/\*{10})\s+){3}(?:\d+|\/\*{10}){1}\s*]{1}/g); + var byteRangePlaceholder = byteRangeStrings.find(function(a_str){ + return a_str.includes("/**********"); + }); + if(!byteRangePlaceholder){ + throw new Error("no signature placeholder"); + } + var byteRangePos = pdfstr.indexOf(byteRangePlaceholder); + var byteRangeEnd = byteRangePos + byteRangePlaceholder.length; + var contentsTagPos = pdfstr.indexOf('/Contents ', byteRangeEnd); + var placeholderPos = pdfstr.indexOf('<', contentsTagPos); + var placeholderEnd = pdfstr.indexOf('>', placeholderPos); + var placeholderLengthWithBrackets = placeholderEnd + 1 - placeholderPos; + var placeholderLength = placeholderLengthWithBrackets - 2; + var byteRange = [0, 0, 0, 0]; + byteRange[1] = placeholderPos; + byteRange[2] = byteRange[1] + placeholderLengthWithBrackets; + byteRange[3] = pdfstr.length - byteRange[2]; + var actualByteRange = "/ByteRange [" + byteRange.join(" ") +"]"; + actualByteRange += ' '.repeat(byteRangePlaceholder.length - actualByteRange.length); + // Replace the /ByteRange placeholder with the actual ByteRange + pdfstr = pdfstr.slice(0, byteRangePos) + actualByteRange + pdfstr.slice(byteRangeEnd); + // Remove the placeholder signature + pdfstr = pdfstr.slice(0, byteRange[1]) + pdfstr.slice(byteRange[2], byteRange[2] + byteRange[3]); + + // Convert Buffer P12 to a forge implementation. + var p12Asn1 = forge.asn1.fromDer(this.opt.p12cert); + var p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, true, this.opt.pwd); + + // Extract safe bags by type. + // We will need all the certificates and the private key. + var certBags = p12.getBags({ + "bagType": forge.pki.oids.certBag, + })[forge.pki.oids.certBag]; + var keyBags = p12.getBags({ + "bagType": forge.pki.oids.pkcs8ShroudedKeyBag, + })[forge.pki.oids.pkcs8ShroudedKeyBag]; + + var privateKey = keyBags[0].key; + // Here comes the actual PKCS#7 signing. + var p7 = forge.pkcs7.createSignedData(); + // Start off by setting the content. + p7.content = forge.util.createBuffer(pdfstr); + + // Then add all the certificates (-cacerts & -clcerts) + // Keep track of the last found client certificate. + // This will be the public key that will be bundled in the signature. + var cert = null; + Object.keys(certBags).forEach(function(a_ele){ + var a_cert = certBags[a_ele].cert; + + p7.addCertificate(a_cert); + + // Try to find the certificate that matches the private key. + if(privateKey.n.compareTo(a_cert.publicKey.n) === 0 + && privateKey.e.compareTo(a_cert.publicKey.e) === 0){ + cert = a_cert; + } + }); + if(!cert){ + throw new Error("Failed to find a certificate."); + } + + // Add a sha256 signer. That's what Adobe.PPKLite adbe.pkcs7.detached expects. + p7.addSigner({ + key: privateKey, + certificate: cert, + digestAlgorithm: forge.pki.oids.sha256, + authenticatedAttributes: [ + { + "type": forge.pki.oids.contentType, + "value": forge.pki.oids.data, + }, { + "type": forge.pki.oids.messageDigest, + }, { + "type": forge.pki.oids.signingTime, + "value": this.opt.signdate, + }, + ], + }); + + // Sign in detached mode. + p7.sign({"detached": true}); + // Check if the PDF has a good enough placeholder to fit the signature. + var sighex = forge.asn1.toDer(p7.toAsn1()).toHex(); + // placeholderLength represents the length of the HEXified symbols but we're + // checking the actual lengths. + if(sighex.length > placeholderLength){ + throw new Error("Signature is too big."); + }else{ + // Pad the signature with zeroes so the it is the same length as the placeholder + sighex += "0".repeat(placeholderLength - sighex.length); + } + // Place it in the document. + pdfstr = pdfstr.slice(0, byteRange[1]) + "<" + sighex + ">" + pdfstr.slice(byteRange[1]); + + return this.rawToBlob(pdfstr, "application/pdf"); + } + + /** + * @private + * @param {string} raw + * @param {string=} typ + * @return {Blob} + */ + rawToBlob(raw, typ){ + var arr = new Uint8Array(raw.length); + for(var i=0; i} */ + this.rect = [0, 0, 0, 0]; + /** @private @type {SignDrawInfo} */ + this.drawinf = null; + + if(drawinf){ + this.drawinf = drawinf; + if(this.drawinf.pageidx){ + this.pgidx = this.drawinf.pageidx; + } + } + } + + /** + * @public + * @return {number} + */ + getPageIndex(){ + return this.pgidx; + } + + /** + * @public + * @return {Array} + */ + getSignRect(){ + return this.rect; + } + + /** + * @public + * @param {PDFLib.PDFDocument} pdfdoc + * @param {string=} signame + * @return {PDFLib.PDFRef} The unique reference assigned to the signature stream + */ + createStream(pdfdoc, signame){ + if(!this.drawinf){ + return null; + }else if(!(this.drawinf.img || (this.drawinf.font && this.drawinf.font))){ + return null; + } + + /** @type {Array} */ + var pages = pdfdoc.getPages(); + /** @type {PDFLib.PDFPage} */ + var page = null; + if(this.pgidx < pages.length){ + page = pages[this.pgidx]; + }else{ + throw new Error("Page index is overflow to pdf pages."); + } + var pgrot = page.getRotation(); + pgrot.angle = PDFLib.toDegrees(pgrot) % 360; + pgrot.type = PDFLib.RotationTypes.Degrees; + var pgsz = page.getSize(); + var areainf = this.calcAreaInf(pgsz, pgrot.angle, this.drawinf.area); + + // resources object + var rscObj = {}; + /** @type {Array} */ + var sigOprs = []; + var imgName = signame ? signame.concat("Img") : "SigImg"; + var fontName = signame ? signame.concat("Font") : "SigFont"; + if(this.drawinf.img){ + // Get scaled image size + var imgsz = this.drawinf.img.size(); + var tmp = areainf.w * imgsz.height / imgsz.width; + if(tmp <= areainf.h){ + areainf.h = tmp; + }else{ + areainf.w = areainf.h * imgsz.width / imgsz.height; + } + + rscObj["XObject"] = { + [imgName]: this.drawinf.img.ref, + }; + sigOprs = sigOprs.concat(PDFLib.drawImage(imgName, this.calcDrawImgInf(pgrot, areainf))); + } + if(this.drawinf.font){ + rscObj["Font"] = { + [fontName]: this.drawinf.font.ref, + }; + } + + this.rect = this.calcRect(pgrot.angle, areainf); + + var frmDict = pdfdoc.context.obj({ + "Type": "XObject", + "Subtype": "Form", + "FormType": 1, + "BBox": [0, 0, areainf.w, areainf.h], + "Resources": rscObj, + }); + var strm = PDFLib.PDFContentStream.of(frmDict, sigOprs, false); + return pdfdoc.context.register(strm); + } + + /** + * Calculate area informations for drawing signature after rotate + * + * @private + * @param {Object} pgsz // { width, height } + * @param {number} angle + * @param {SignAreaInfo} visinf + * @return {SignAreaInfo} + */ + calcAreaInf(pgsz, angle, visinf){ + var ret = Object.assign({}, visinf); + // Calculate position after rotate + switch(angle){ + case 90: + ret.w = visinf.h; + ret.h = visinf.w; + ret.x = visinf.y; + ret.y = visinf.x; + break; + case 180: + case -180: + ret.x = pgsz.width - visinf.x; + break; + case 270: + case -90: + ret.w = visinf.h; + ret.h = visinf.w; + ret.x = pgsz.width - visinf.y; + ret.y = pgsz.height - visinf.x; + break; + default: + ret.y = pgsz.height - visinf.y; + } + return ret; + } + + /** + * @private + * @param {number} angle + * @param {SignAreaInfo} areainf // { x, y, w, h } + * @return {Array} + */ + calcRect(angle, areainf){ + var rect = [0, 0, 0, 0]; + rect[0] = areainf.x; + rect[1] = areainf.y; + switch(angle){ + case 90: + rect[2] = areainf.x - areainf.h; + rect[3] = areainf.y + areainf.w; + break; + case 180: + case -180: + rect[2] = areainf.x - areainf.w; + rect[3] = areainf.y - areainf.h; + break; + case 270: + case -90: + rect[2] = areainf.x + areainf.h; + rect[3] = areainf.y - areainf.w; + break; + default: + rect[2] = areainf.x + areainf.w; + rect[3] = areainf.y + areainf.h; + } + return rect; + } + + /** + * Calculate informations for drawing image after rotate + * + * @private + * @param {PDFLib.Rotation} rot + * @param {SignAreaInfo} areainf // { x, y, w, h } + * @return {Object} // { x, y, width, height, rotate, xSkew, ySkew } + */ + calcDrawImgInf(rot, areainf){ + var ret = { + "x": 0, + "y": 0, + "width": areainf.w, + "height": areainf.h, + "rotate": rot, + "xSkew": PDFLib.degrees(0), + "ySkew": PDFLib.degrees(0), + }; + switch(rot.angle){ + case 90: + ret["x"] = areainf.w; + ret["width"] = areainf.h; + ret["height"] = areainf.w; + break; + case 180: + case -180: + ret["x"] = areainf.w; + ret["y"] = areainf.h; + break; + case 270: + case -90: + ret["y"] = areainf.h; + ret["width"] = areainf.h; + ret["height"] = areainf.w; + break; + } + return ret; + } +} \ No newline at end of file