From db7b45d445cc6570b6ff4f4aa0959ecba96ddf29 Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 13:40:37 +0200 Subject: [PATCH] refactor: extract RsaSigner for crypto-agility seam --- lib/zganode.d.ts | 7 ++ lib/zgapdfsigner.js | 49 +++++++++++- package.json | 7 +- test4node.js | 12 ++- tests/sign-integration.test.js | 34 +++++++++ tests/signer.test.js | 30 ++++++++ testutil/fixtures.js | 43 +++++++---- testutil/make-demo.js | 135 +++++++++++++++++++++++++++++++++ 8 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 tests/sign-integration.test.js create mode 100644 tests/signer.test.js create mode 100644 testutil/make-demo.js diff --git a/lib/zganode.d.ts b/lib/zganode.d.ts index 47ac09c..f9d59ce 100644 --- a/lib/zganode.d.ts +++ b/lib/zganode.d.ts @@ -100,6 +100,13 @@ export declare class PdfCryptor { encryptPdf(pdf: PDFLib.PDFDocument | Array | Uint8Array | ArrayBuffer | string, ref?: PDFLib.PDFRef): Promise; encryptObject(num: number, val: PDFLib.PDFObject): void; } +export declare class RsaSigner { + constructor(privateKey: forge.pki.rsa.PrivateKey, certificate: forge.pki.Certificate); + privateKey: forge.pki.rsa.PrivateKey; + certificate: forge.pki.Certificate; + getDigestAlgorithmOid(): string; + sign(data: string): string; +} export declare class PdfSigner { constructor(signopt: SignOption); sign(pdf: PDFLib.PDFDocument | Array | Uint8Array | ArrayBuffer | string, cypopt?: EncryptOption): Promise; diff --git a/lib/zgapdfsigner.js b/lib/zgapdfsigner.js index b5d76e7..3e5da8c 100644 --- a/lib/zgapdfsigner.js +++ b/lib/zgapdfsigner.js @@ -216,6 +216,42 @@ z.NewRefMap = class extends Map{ /** @type {z.NewRefMap} */ z.newRefs = new z.NewRefMap(); +/** + * Crypto-agility seam: a Signer produces the raw signature bytes for the + * PKCS#7 SignerInfo, keeping the signature algorithm out of the PDF assembly. + * RsaSigner captures the current RSA + SHA-256 (PKCS#1 v1.5) behavior. + */ +z.RsaSigner = class{ + /** + * @param {forge.pki.rsa.PrivateKey} privateKey + * @param {forge_cert} certificate + */ + constructor(privateKey, certificate){ + /** @type {forge.pki.rsa.PrivateKey} */ + this.privateKey = privateKey; + /** @type {forge_cert} */ + this.certificate = certificate; + } + + /** + * @return {string} the digest algorithm OID used for the PKCS#7 SignerInfo + */ + getDigestAlgorithmOid(){ + return forge.pki.oids.sha256; + } + + /** + * @param {string} data + * @return {string} raw signature bytes + */ + sign(data){ + /** @type {forge.md.digest} */ + var md = forge.md.sha256.create(); + md.update(data); + return this.privateKey.sign(md); + } +}; + z.PdfSigner = class{ /** * @param {SignOption} signopt @@ -1012,11 +1048,18 @@ z.PdfSigner = class{ p7.addCertificate(a_cert); }); - // Add a sha256 signer. That's what Adobe.PPKLite adbe.pkcs7.detached expects. + // Build the signer for this operation. The Signer owns the signature + // algorithm choice (digest + key), keeping it out of the PKCS#7 assembly + // so it can later be swapped without touching this code. node-forge still + // performs the RSA signing via the supplied key (shallow seam). + /** @type {z.RsaSigner} */ + var signer = new z.RsaSigner(_this.privateKey, _this.cchain.getSignCert()); + + // Add the signer. sha256 is what Adobe.PPKLite adbe.pkcs7.detached expects. p7.addSigner({ key: _this.privateKey, - certificate: _this.cchain.getSignCert(), - digestAlgorithm: forge.pki.oids.sha256, + certificate: signer.certificate, + digestAlgorithm: signer.getDigestAlgorithmOid(), authenticatedAttributes: [ { "type": forge.pki.oids.contentType, diff --git a/package.json b/package.json index d3e5052..5b2b485 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,10 @@ "scripts": { "build": "./build.sh", "server": "node test4node.js webserver", - "test": "node test4node.js ${pfxpwd}", - "test1": "node --test", - "test2": "node test4node.js fetch" + "test": "node --test", + "test:fixtures": "node testutil/make-demo.js", + "test:manual": "node test4node.js", + "test:fetch": "node test4node.js fetch" }, "dependencies": { "follow-redirects": "^1.16.0", diff --git a/test4node.js b/test4node.js index 284e0e7..ecd712c 100644 --- a/test4node.js +++ b/test4node.js @@ -156,7 +156,10 @@ async function main1(angle){ var imgPath = m_path.join(__dirname, workpath+"_test.png"); /** @type {string} */ var fontPath = m_path.join(__dirname, workpath+"_test.ttf"); - // var fontPath = Zga.PDFLib.StandardFonts.CourierBold; + if(!m_fs.existsSync(fontPath)){ + // Fall back to a built-in font when no custom TTF has been supplied. + fontPath = Zga.PDFLib.StandardFonts.CourierBold; + } if(process.argv.length > 3){ pfxPath = process.argv[2]; @@ -171,7 +174,12 @@ async function main1(angle){ } if(pfxPath){ - await sign_protect(pdfPath, pfxPath, ps, 1, imgPath, "あいうえおあいうえおか\r\n\nThis is a test of text!\n", fontPath); + // Standard fonts are WinAnsi-encoded and cannot render Japanese text. + if(Zga.PDFLib.isStandardFont(fontPath)){ + await sign_protect(pdfPath, pfxPath, ps, 1, imgPath, "This is a test of text!\n", fontPath); + }else{ + await sign_protect(pdfPath, pfxPath, ps, 1, imgPath, "あいうえおあいうえおか\r\n\nThis is a test of text!\n", fontPath); + } if(Zga.PDFLib.isStandardFont(fontPath)){ pdfPath = await sign_protect(pdfPath, pfxPath, ps, 2, imgPath, "This is an another test of text!\n", fontPath); pdfPath = await sign_protect(pdfPath, pfxPath, ps, 0, undefined, "This is a test for same font!\n", fontPath); diff --git a/tests/sign-integration.test.js b/tests/sign-integration.test.js new file mode 100644 index 0000000..5358dc9 --- /dev/null +++ b/tests/sign-integration.test.js @@ -0,0 +1,34 @@ +"use strict"; + +const {test} = require("node:test"); +const assert = require("node:assert"); +const Zga = require("../lib/zganode.js"); +const {makeP12} = require("../testutil/fixtures.js"); + +const PWD = "test-pw"; + +/** + * Build a minimal one-page PDF with pdf-lib. + * @return {Promise} + */ +async function minimalPdf(){ + const doc = await Zga.PDFLib.PDFDocument.create(); + doc.addPage([300, 300]); + return doc.save(); +} + +// End-to-end safety net for the crypto-agility refactor: signing a real PDF +// must keep producing a valid detached PKCS#7 signature dictionary. +test("PdfSigner.sign produces a detached PKCS#7 signature over a real PDF", async () => { + const pdfBytes = await minimalPdf(); + const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD}); + + const signed = await signer.sign(pdfBytes); + const dump = Buffer.from(signed).toString("latin1"); + + assert.ok(signed instanceof Uint8Array, "returns a Uint8Array"); + assert.ok(signed.length > pdfBytes.length, "signed output is larger than the input"); + assert.match(dump, /adbe\.pkcs7\.detached/, "uses the detached PKCS#7 SubFilter"); + assert.match(dump, /ByteRange/, "embeds a ByteRange"); + assert.match(dump, /\/Type\s*\/Sig/, "embeds a signature dictionary"); +}); diff --git a/tests/signer.test.js b/tests/signer.test.js new file mode 100644 index 0000000..8b81bb7 --- /dev/null +++ b/tests/signer.test.js @@ -0,0 +1,30 @@ +"use strict"; + +const {test} = require("node:test"); +const assert = require("node:assert"); +const Zga = require("../lib/zganode.js"); +const {makeKeyCert} = require("../testutil/fixtures.js"); +const forge = Zga.forge; + +// Crypto-agility seam (Update 4): a Signer abstraction decouples the signature +// algorithm from the PDF/PKCS#7 assembly. RsaSigner captures the current +// RSA + SHA-256 behavior behind that interface. + +test("RsaSigner.sign produces a SHA-256 RSA signature that verifies against the certificate public key", () => { + const {privateKey, certificate} = makeKeyCert(3072); + const signer = new Zga.RsaSigner(privateKey, certificate); + const data = "hello zgapdfsigner"; + + const signature = signer.sign(data); + + const md = forge.md.sha256.create(); + md.update(data); + assert.strictEqual(certificate.publicKey.verify(md.digest().bytes(), signature), true); +}); + +test("RsaSigner reports SHA-256 as its digest algorithm OID", () => { + const {privateKey, certificate} = makeKeyCert(3072); + const signer = new Zga.RsaSigner(privateKey, certificate); + + assert.strictEqual(signer.getDigestAlgorithmOid(), forge.pki.oids.sha256); +}); diff --git a/testutil/fixtures.js b/testutil/fixtures.js index 4e24bcd..af06864 100644 --- a/testutil/fixtures.js +++ b/testutil/fixtures.js @@ -3,22 +3,19 @@ const Zga = require("../lib/zganode.js"); const forge = Zga.forge; -/** @type {Map} in-process cache to avoid regenerating keys */ -const cache = new Map(); +/** @type {Map} key cache */ +const keyCache = new Map(); /** - * Build a self-signed PKCS#12 (returned as a DER binary string) carrying an - * RSA key of the given modulus length. Used to exercise the CCN-STIC-221 - * key-length guard in loadP12cert without shipping binary fixtures. + * Generate (and cache) a self-signed RSA key pair + certificate of the given + * modulus length, as raw node-forge objects. * * @param {number} bits RSA modulus length in bits. - * @param {string} pwd PKCS#12 password. - * @return {string} DER-encoded PKCS#12 as a binary string. + * @return {{privateKey: *, publicKey: *, certificate: *}} */ -function makeP12(bits, pwd){ - const cacheKey = bits + ":" + pwd; - if(cache.has(cacheKey)){ - return cache.get(cacheKey); +function makeKeyCert(bits){ + if(keyCache.has(bits)){ + return keyCache.get(bits); } const keys = forge.pki.rsa.generateKeyPair({bits: bits, e: 0x10001}); const cert = forge.pki.createCertificate(); @@ -30,10 +27,24 @@ function makeP12(bits, pwd){ cert.setSubject(attrs); cert.setIssuer(attrs); cert.sign(keys.privateKey, forge.md.sha256.create()); - const asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], pwd, {algorithm: "3des"}); - const der = forge.asn1.toDer(asn1).getBytes(); - cache.set(cacheKey, der); - return der; + const entry = {privateKey: keys.privateKey, publicKey: keys.publicKey, certificate: cert}; + keyCache.set(bits, entry); + return entry; } -module.exports = {makeP12: makeP12}; +/** + * Build a self-signed PKCS#12 (returned as a DER binary string) carrying an + * RSA key of the given modulus length. Used to exercise the CCN-STIC-221 + * key-length guard in loadP12cert without shipping binary fixtures. + * + * @param {number} bits RSA modulus length in bits. + * @param {string} pwd PKCS#12 password. + * @return {string} DER-encoded PKCS#12 as a binary string. + */ +function makeP12(bits, pwd){ + const kc = makeKeyCert(bits); + const asn1 = forge.pkcs12.toPkcs12Asn1(kc.privateKey, [kc.certificate], pwd, {algorithm: "3des"}); + return forge.asn1.toDer(asn1).getBytes(); +} + +module.exports = {makeKeyCert: makeKeyCert, makeP12: makeP12}; diff --git a/testutil/make-demo.js b/testutil/make-demo.js new file mode 100644 index 0000000..047d32b --- /dev/null +++ b/testutil/make-demo.js @@ -0,0 +1,135 @@ +"use strict"; + +/** + * Generate the demo fixtures that test4node.js reads from the (gitignored) + * `test/` directory: a source PDF for every rotation the manual demo walks + * through, a stamp image, and a self-signed PKCS#12 certificate. + * + * Usage: node testutil/make-demo.js [pfxPassword] + */ + +const m_fs = require("fs"); +const m_path = require("path"); +const m_zlib = require("zlib"); +const Zga = require("../lib/zganode.js"); +const {makeP12} = require("./fixtures.js"); + +/** @type {string} Output directory, matching the `workpath` of test4node.js. */ +const outDir = m_path.join(__dirname, "..", "test"); +/** @type {Array} Page rotations the manual demo iterates over. */ +const ROTATIONS = [0, 90, 180, 270]; +/** @type {string} */ +const DEFAULT_PWD = "zgatest"; +/** @type {number} CCN-STIC-221 requires at least 3000 bits. */ +const RSA_BITS = 3072; + +/** + * Build a one-page demo PDF with the given page rotation. + * + * @param {number} angle Page rotation in degrees. + * @return {Promise} + */ +async function makePdf(angle){ + const doc = await Zga.PDFLib.PDFDocument.create(); + const font = await doc.embedFont(Zga.PDFLib.StandardFonts.Helvetica); + const page = doc.addPage([595, 842]); + page.drawText("zgapdfsigner demo document", {x: 60, y: 760, size: 18, font: font}); + page.drawText("Page rotation: " + angle + " degrees", {x: 60, y: 730, size: 12, font: font}); + page.drawText("Generated by testutil/make-demo.js", {x: 60, y: 710, size: 10, font: font}); + if(angle){ + page.setRotation(Zga.PDFLib.degrees(angle)); + } + return doc.save(); +} + +/** + * CRC-32 as specified by the PNG format. + * + * @param {Buffer} buf + * @return {number} + */ +function crc32(buf){ + let crc = 0xffffffff; + for(let i = 0; i < buf.length; i++){ + crc ^= buf[i]; + for(let bit = 0; bit < 8; bit++){ + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +/** + * Wrap a payload into a length-prefixed, CRC-suffixed PNG chunk. + * + * @param {string} type Four-character chunk type. + * @param {Buffer} data + * @return {Buffer} + */ +function pngChunk(type, data){ + const head = Buffer.alloc(4); + head.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const tail = Buffer.alloc(4); + tail.writeUInt32BE(crc32(body), 0); + return Buffer.concat([head, body, tail]); +} + +/** + * Build a small solid-color PNG, used as the signature stamp image. + * + * @return {Buffer} + */ +function makePng(){ + const size = 64; + const stride = 1 + size * 3; + const raw = Buffer.alloc(size * stride); + for(let y = 0; y < size; y++){ + const row = y * stride; + raw[row] = 0; // filter type: none + for(let x = 0; x < size; x++){ + const px = row + 1 + x * 3; + const edge = x < 4 || y < 4 || x >= size - 4 || y >= size - 4; + raw[px] = edge ? 0x1f : 0x8a; + raw[px + 1] = edge ? 0x6f : 0xc8; + raw[px + 2] = edge ? 0xd5 : 0xf0; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // color type: truecolor + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk("IDAT", m_zlib.deflateSync(raw)), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +async function main(){ + const pwd = process.argv[2] || DEFAULT_PWD; + m_fs.mkdirSync(outDir, {recursive: true}); + + for(const angle of ROTATIONS){ + const name = "_test" + (angle ? "_" + angle : "") + ".pdf"; + m_fs.writeFileSync(m_path.join(outDir, name), await makePdf(angle)); + console.log("Wrote " + m_path.join(outDir, name)); + } + + const pngPath = m_path.join(outDir, "_test.png"); + m_fs.writeFileSync(pngPath, makePng()); + console.log("Wrote " + pngPath); + + const pfxPath = m_path.join(outDir, "_test.pfx"); + m_fs.writeFileSync(pfxPath, Buffer.from(makeP12(RSA_BITS, pwd), "latin1")); + console.log("Wrote " + pfxPath + " (password: " + pwd + ")"); + + console.log("\nRun the manual demo with: npm run test:manual -- " + pwd); +} + +main().catch(function(err){ + console.error(err); + process.exit(1); +});