diff --git a/.gitignore b/.gitignore index 4ad1f15..b40562e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,11 @@ typings/ # Optional npm cache directory .npm +# pnpm +.pnpm-store/ +pnpm-lock.yaml +pnpm-debug.log* + # Optional eslint cache .eslintcache diff --git a/README.md b/README.md index 62ee3e0..e9af894 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,58 @@ And I use this name to hope the merits from this application will be dedicated t * 256bit AES Encryption * Set public-key certificate protection to a pdf. Supported algorithms are as same as the password protection. +* Optionally enforce the algorithms approved by [CCN-STIC-221](https://www.ccn-cert.cni.es/). + +## Enforcing CCN-STIC-221 (optional) + +[CCN-STIC-221](https://www.ccn-cert.cni.es/) is the approved-algorithms guide of +the Spanish national cryptology centre. Set `strictCrypto: true` to make this +tool refuse anything the guide does not authorize. __It is off by default, so +nothing changes unless you ask for it.__ + +When it is on: + +* __Only AES-256 encryption is accepted.__ RC4 is a stream cipher and is not + authorized, and the RC4 / AES-128 PDF security handlers derive their key with + MD5. Any other mode throws. +* __Signing keys must be RSA of at least 3000 bits, with log2(e) > 16.__ A + certificate carrying a shorter key is rejected when it is loaded. The standard + public exponent 65537 already satisfies the exponent rule. + +```js +var sopt = { + p12cert: cert, + pwd: pwd, + strictCrypto: true, +}; +var eopt = { + mode: Zga.Crypto.Mode.AES_256, // anything else throws under strictCrypto + userpwd: upwd, +}; +// strictCrypto on the SignOption also applies to the encryption step. +var u8arr = await new Zga.PdfSigner(sopt).sign(pdf, eopt); +``` + +`PdfCryptor` accepts the same flag when used on its own: + +```js +var cyptor = new Zga.PdfCryptor({ + mode: Zga.Crypto.Mode.AES_256, + userpwd: upwd, + strictCrypto: true, +}); +``` + +`minRsaKeyBits` sets the minimum RSA modulus length. Setting it turns on the key +length check on its own, and it overrides the 3000-bit default of `strictCrypto`: + +```js +var sopt = { + p12cert: cert, + pwd: pwd, + minRsaKeyBits: 2048, // rejects keys under 2048bit; not CCN-STIC-221 compliant +}; +``` ## About signing with [TSA](https://github.com/zboris12/zgapdfsigner/wiki/API#note) and [LTV](https://github.com/zboris12/zgapdfsigner/wiki/API#note) diff --git a/closure/zb-externs.js b/closure/zb-externs.js index 49c123b..8cfc8dd 100644 --- a/closure/zb-externs.js +++ b/closure/zb-externs.js @@ -95,6 +95,15 @@ var SignDrawInfo; * 1 : auto; Try using ocsp only to enable the LTV first; If can't, try using crl to enable the LTV. * 2 : crl only; Only try using crl to enable the LTV. * + * strictCrypto: Enforce the algorithms approved by CCN-STIC-221. Defaults to + * false, which keeps the current behaviour. When true, signing keys must be + * RSA of at least 3000 bits with a public exponent satisfying log2(e) > 16. + * It also turns on strict encryption when an EncryptOption is passed to sign(). + * + * minRsaKeyBits: Minimum accepted RSA modulus length in bits. Setting it enables + * the key length check on its own and overrides the 3000-bit default of + * strictCrypto. + * * @typedef * {{ * p12cert: (Array|Uint8Array|ArrayBuffer|string|undefined), @@ -107,6 +116,8 @@ var SignDrawInfo; * signame: (string|undefined), * drawinf: (SignDrawInfo|undefined), * ltv: (number|undefined), + * strictCrypto: (boolean|undefined), + * minRsaKeyBits: (number|undefined), * debug: (boolean|undefined), * }} */ @@ -136,6 +147,8 @@ var PubKeyInfo; * * pubkeys: Array of recipients containing public-key certificates ('c') and permissions ('p'). If want to encrypt the pdf by the certificate of signing, just apply a PubKeyInfo without c. * + * strictCrypto: Enforce the algorithms approved by CCN-STIC-221. Defaults to false, which keeps every encryption mode available. When true, only AES-256 is accepted. + * * @typedef * {{ * mode: Zga.Crypto.Mode, @@ -143,6 +156,7 @@ var PubKeyInfo; * userpwd: (string|undefined), * ownerpwd: (string|undefined), * pubkeys: (Array|undefined), + * strictCrypto: (boolean|undefined), * }} */ var EncryptOption; diff --git a/lib/zganode.d.ts b/lib/zganode.d.ts index 7e7699f..347ac37 100644 --- a/lib/zganode.d.ts +++ b/lib/zganode.d.ts @@ -24,6 +24,7 @@ export type EncryptOption = { userpwd?: string; ownerpwd?: string; pubkeys?: Array; + strictCrypto?: boolean; }; export type PubKeyInfo = { c?: Array | Uint8Array | ArrayBuffer | string | forge.pki.Certificate; @@ -77,6 +78,8 @@ export type SignOption = { signame?: string; drawinf?: SignDrawInfo; ltv?: number; + strictCrypto?: boolean; + minRsaKeyBits?: number; debug?: boolean; }; export type TsaServiceInfo = { @@ -98,6 +101,15 @@ 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; + getSignatureAlgorithmOid(): string; + sign(data: string): string; +} +export declare function createSigner(privateKey: forge.pki.rsa.PrivateKey, certificate: forge.pki.Certificate): RsaSigner; export declare class PdfSigner { constructor(signopt: SignOption); sign(pdf: PDFLib.PDFDocument | Array | Uint8Array | ArrayBuffer | string, cypopt?: EncryptOption): Promise; diff --git a/lib/zgapdfcryptor.js b/lib/zgapdfcryptor.js index be7e4d8..fc80903 100644 --- a/lib/zgapdfcryptor.js +++ b/lib/zgapdfcryptor.js @@ -308,6 +308,17 @@ z.PdfCryptor = class{ this.pubkeys = encopt.pubkeys; /** @private @type {z.Crypto.Mode} */ this.mode = /** @type {z.Crypto.Mode} */(encopt.mode); + + // CCN-STIC-221: stream ciphers such as RC4 are not authorized, + // and the MD5-based key derivation used by the RC4 / AES-128 PDF security + // handlers is not authorized. Only the AES-256 handler avoids both. + // Enforcement is opt-in, so every existing mode keeps working by default. + if(encopt.strictCrypto && this.mode !== z.Crypto.Mode.AES_256){ + throw new Error("Encryption mode " + this.mode + " is not authorized by " + + "CCN-STIC-221 (RC4 and MD5-based key derivation are prohibited). " + + "Use AES-256, or drop strictCrypto to allow legacy encryption."); + } + /** @private @type {Array|undefined} */ this.permissions = encopt.permissions; /** @private @type {string} */ diff --git a/lib/zgapdfsigner.js b/lib/zgapdfsigner.js index 7a14dbc..f79e61b 100644 --- a/lib/zgapdfsigner.js +++ b/lib/zgapdfsigner.js @@ -216,6 +216,65 @@ 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; + } + + /** + * @return {string} the signature algorithm OID for the PKCS#7 SignerInfo + */ + getSignatureAlgorithmOid(){ + return forge.pki.oids.sha256WithRSAEncryption; + } + + /** + * @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); + } +}; + +/** + * Select a Signer implementation for the given key. Today only RSA keys are + * supported; this factory is the single place to extend the seam with ECDSA + * or post-quantum signers without touching the PDF assembly. + * + * @param {forge.pki.rsa.PrivateKey} privateKey + * @param {forge_cert} certificate + * @return {z.RsaSigner} + */ +z.createSigner = function(privateKey, certificate){ + if(privateKey && privateKey.n && privateKey.e){ + return new z.RsaSigner(privateKey, certificate); + } + throw new Error("Unsupported signing key type; only RSA keys are supported."); +}; + z.PdfSigner = class{ /** * @param {SignOption} signopt @@ -428,6 +487,11 @@ z.PdfSigner = class{ }); } } + // A signer asking for CCN-STIC-221 enforcement expects it to cover + // the encryption too, unless the EncryptOption says otherwise. + if(_this.opt.strictCrypto && cypopt.strictCrypto === undefined){ + cypopt.strictCrypto = true; + } /** @type {Zga.PdfCryptor} */ _this.cyptr = new z.PdfCryptor(cypopt); await _this.cyptr.encryptPdf(pdfdoc, encref); @@ -680,6 +744,33 @@ z.PdfSigner = class{ })[forge.pki.oids.pkcs8ShroudedKeyBag]; _this.privateKey = keyBags[0].key; + // CCN-STIC-221: Reject certificates whose RSA parameters are not approved, + // so a non-compliant certificate cannot be used to sign. Enforcement is + // opt-in via opt.strictCrypto, so existing callers are unaffected. + // Setting opt.minRsaKeyBits enables the modulus check on its own and + // overrides the 3000-bit default. + if(_this.privateKey && _this.privateKey.n && _this.opt){ + /** @type {boolean} */ + var strictCrypto = !!_this.opt.strictCrypto; + /** @type {boolean} */ + var hasMinRsaKeyBits = typeof _this.opt.minRsaKeyBits === "number"; + if(strictCrypto || hasMinRsaKeyBits){ + /** @type {number} */ + var minRsaKeyBits = hasMinRsaKeyBits ? _this.opt.minRsaKeyBits : 3000; + /** @type {number} */ + var rsaKeyBits = _this.privateKey.n.bitLength(); + if(rsaKeyBits < minRsaKeyBits){ + throw new Error("RSA key length " + rsaKeyBits + " bits is below the " + + minRsaKeyBits + "-bit minimum required by CCN-STIC-221."); + } + } + // CCN-STIC-221 requires the public exponent to satisfy log2(e) > 16. + // The de-facto standard value 65537 already meets this. + if(strictCrypto && _this.privateKey.e && _this.privateKey.e.bitLength() <= 16){ + throw new Error("RSA public exponent is too small; CCN-STIC-221 requires log2(e) > 16."); + } + } + /** @type {Array} */ var certs = []; /** @type {number} */ @@ -993,11 +1084,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 = z.createSigner(_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 1dfc280..8a7d644 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zgapdfsigner", - "version": "2.7.6", + "version": "2.8.0", "author": "zboris12", "description": "A javascript tool to sign a pdf or set protection to a pdf in web browser, Google Apps Script and nodejs.", "homepage": "https://github.com/zboris12/zgapdfsigner", @@ -33,8 +33,10 @@ "scripts": { "build": "./build.sh", "server": "node test4node.js webserver", - "test": "node test4node.js ${pfxpwd}", - "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..38d1fad 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); @@ -189,23 +197,38 @@ async function main1(angle){ // test urlFetch async function main2(){ - /** @type {Uint8Array} */ - var u8arr = await Zga.urlFetch("http://localhost:8080", { - "headers": { - "testzb": "pineapple" - } - }); - // /** @type {string} */ - // var str = btoa(Zga.u8arrToRaw(u8arr)); - /** @type {TextDecoder} */ - var txtdec = new TextDecoder("utf-8"); - /** @type {string} */ - var str = txtdec.decode(u8arr); - console.log(str); + // Boot a throwaway server on an ephemeral port so this test is self + // contained and cannot collide with an already running `npm run server`. + /** @type {http.Server} */ + var srv = await startWebserver(0); + /** @type {number} */ + var port = srv.address().port; + try{ + /** @type {Uint8Array} */ + var u8arr = await Zga.urlFetch("http://localhost:"+port, { + "headers": { + "testzb": "pineapple" + } + }); + // /** @type {string} */ + // var str = btoa(Zga.u8arrToRaw(u8arr)); + /** @type {TextDecoder} */ + var txtdec = new TextDecoder("utf-8"); + /** @type {string} */ + var str = txtdec.decode(u8arr); + console.log(str); + }finally{ + srv.close(); + } } -function webserver(){ - require("http").createServer(function(req, res){ +/** + * @param {number} port Port to listen on. 0 picks a free ephemeral port. + * @return {Promise} The listening server. + */ +function startWebserver(port){ + /** @type {http.Server} */ + var srv = require("http").createServer(function(req, res){ if(req.method == "GET"){ if(req.headers["testzb"]){ res.setHeader("Access-Control-Allow-Origin", "*"); @@ -233,7 +256,14 @@ function webserver(){ res.statusMessage = "CORS OK"; res.end(); } - }).listen(8080, function(){console.log("Server http://localhost:8080")}); + }); + return new Promise(function(resolve, reject){ + srv.once("error", reject); + srv.listen(port, function(){ + console.log("Server http://localhost:"+srv.address().port); + resolve(srv); + }); + }); } async function main(){ @@ -246,10 +276,21 @@ async function main(){ } } -if(process.argv[2] == "webserver"){ - webserver(); -}else if(process.argv[2] == "fetch"){ - main2(); -}else{ - main(); +/** + * @param {Promise<*>} p + */ +function run(p){ + p.catch(function(err){ + console.error(err); + process.exitCode = 1; + }); +} + +if(process.argv[2] == "webserver"){ + // Fixed port, because test.html expects the server on 8080. + run(startWebserver(8080)); +}else if(process.argv[2] == "fetch"){ + run(main2()); +}else{ + run(main()); } diff --git a/tests/compliance-encryption.test.js b/tests/compliance-encryption.test.js new file mode 100644 index 0000000..42761d3 --- /dev/null +++ b/tests/compliance-encryption.test.js @@ -0,0 +1,45 @@ +"use strict"; + +const {test} = require("node:test"); +const assert = require("node:assert"); +const Zga = require("../lib/zganode.js"); + +const Mode = Zga.Crypto.Mode; + +// CCN-STIC-221: RC4 (any stream cipher) and MD5-based key derivation are not +// authorized. Only the AES-256 handler avoids both. Enforcement is opt-in via +// strictCrypto, so the default stays backward compatible. The production change +// that makes these fail is removing the mode guard in the PdfCryptor constructor. + +test("allows every legacy mode by default", () => { + assert.doesNotThrow(() => new Zga.PdfCryptor({mode: Mode.RC4_40, userpwd: "x"})); + assert.doesNotThrow(() => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x"})); + assert.doesNotThrow(() => new Zga.PdfCryptor({mode: Mode.AES_128, userpwd: "x"})); +}); + +test("rejects RC4-40 encryption under strictCrypto", () => { + assert.throws( + () => new Zga.PdfCryptor({mode: Mode.RC4_40, userpwd: "x", strictCrypto: true}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("rejects RC4-128 encryption under strictCrypto", () => { + assert.throws( + () => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x", strictCrypto: true}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("rejects AES-128 encryption under strictCrypto", () => { + assert.throws( + () => new Zga.PdfCryptor({mode: Mode.AES_128, userpwd: "x", strictCrypto: true}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("accepts AES-256 encryption under strictCrypto", () => { + assert.doesNotThrow( + () => new Zga.PdfCryptor({mode: Mode.AES_256, userpwd: "x", strictCrypto: true}), + ); +}); diff --git a/tests/compliance-rsa.test.js b/tests/compliance-rsa.test.js new file mode 100644 index 0000000..77991ad --- /dev/null +++ b/tests/compliance-rsa.test.js @@ -0,0 +1,73 @@ +"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"; + +// CCN-STIC-221: RSA modulus must be >= 3000 bits and the public exponent must +// satisfy log2(e) > 16. Enforcement is opt-in via strictCrypto, except that +// setting minRsaKeyBits enables the modulus check on its own. The production +// change that makes these fail is removing the corresponding guard in +// PdfSigner.loadP12cert. + +test("accepts a short RSA key by default", () => { + const p12 = makeP12(2048, PWD); + const signer = new Zga.PdfSigner({}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); + +test("rejects an RSA key shorter than 3000 bits under strictCrypto", () => { + const p12 = makeP12(2048, PWD); + const signer = new Zga.PdfSigner({strictCrypto: true}); + assert.throws( + () => signer.loadP12cert(p12, PWD), + /below the 3000-bit minimum/, + ); +}); + +test("accepts an RSA key of 3072 bits under strictCrypto", () => { + const p12 = makeP12(3072, PWD); + const signer = new Zga.PdfSigner({strictCrypto: true}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); + +test("minRsaKeyBits enforces the key length without strictCrypto", () => { + const p12 = makeP12(1024, PWD); + const signer = new Zga.PdfSigner({minRsaKeyBits: 2048}); + assert.throws( + () => signer.loadP12cert(p12, PWD), + /below the 2048-bit minimum/, + ); +}); + +test("minRsaKeyBits overrides the strictCrypto default", () => { + const p12 = makeP12(2048, PWD); + const signer = new Zga.PdfSigner({strictCrypto: true, minRsaKeyBits: 2048}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); + +// The modulus check runs first, so minRsaKeyBits is lowered here purely to let +// a cheap-to-generate key reach the exponent check. +test("rejects an RSA public exponent with log2(e) <= 16 under strictCrypto", () => { + const p12 = makeP12(1024, PWD, 3); + const signer = new Zga.PdfSigner({strictCrypto: true, minRsaKeyBits: 1024}); + assert.throws( + () => signer.loadP12cert(p12, PWD), + /public exponent is too small/, + ); +}); + +test("accepts a small public exponent when strictCrypto is off", () => { + const p12 = makeP12(1024, PWD, 3); + const signer = new Zga.PdfSigner({minRsaKeyBits: 1024}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); + +test("accepts the standard 65537 public exponent under strictCrypto", () => { + const p12 = makeP12(1024, PWD, 65537); + const signer = new Zga.PdfSigner({strictCrypto: true, minRsaKeyBits: 1024}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 0000000..a23c4ef --- /dev/null +++ b/tests/core.test.js @@ -0,0 +1,26 @@ +"use strict"; + +const {test} = require("node:test"); +const assert = require("node:assert"); +const Zga = require("../lib/zganode.js"); + +// Characterization tests: lock in existing behavior so future refactors +// (e.g. the crypto-agility work) cannot silently change it. + +test("rawToU8arr and u8arrToRaw round-trip binary data", () => { + const raw = "\x00\x01\x41\xff\x7e"; + const u8 = Zga.rawToU8arr(raw); + assert.ok(u8 instanceof Uint8Array); + assert.strictEqual(u8.length, raw.length); + assert.strictEqual(Zga.u8arrToRaw(u8), raw); +}); + +test("Crypto.Mode enumerates the four PDF encryption handlers", () => { + assert.deepStrictEqual(Zga.Crypto.Mode, {RC4_40: 0, RC4_128: 1, AES_128: 2, AES_256: 3}); +}); + +test("getUserPermissionCode clears exactly the print bit when print is blocked", () => { + const base = Zga.Crypto.getUserPermissionCode([], Zga.Crypto.Mode.AES_256); + const noPrint = Zga.Crypto.getUserPermissionCode(["print"], Zga.Crypto.Mode.AES_256); + assert.strictEqual(base - noPrint, Zga.Crypto.Permission["print"]); +}); diff --git a/tests/sign-integration.test.js b/tests/sign-integration.test.js new file mode 100644 index 0000000..0f5c0fe --- /dev/null +++ b/tests/sign-integration.test.js @@ -0,0 +1,85 @@ +"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"); +}); + +// The combined sign + encrypt path: PdfSigner.sign delegates to PdfCryptor, so +// the CCN-STIC-221 mode guard must hold here too, not only on direct use. +test("PdfSigner.sign encrypts the output when an AES-256 EncryptOption is given", async () => { + const pdfBytes = await minimalPdf(); + const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD}); + + const signed = await signer.sign(pdfBytes, { + mode: Zga.Crypto.Mode.AES_256, + permissions: ["copy", "print-high"], + userpwd: "user-pw", + }); + const dump = Buffer.from(signed).toString("latin1"); + + assert.match(dump, /\/Encrypt/, "installs an encryption dictionary"); + assert.match(dump, /adbe\.pkcs7\.detached/, "still carries the detached signature"); +}); + +test("PdfSigner.sign accepts a legacy encryption mode by default", async () => { + const pdfBytes = await minimalPdf(); + const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD}); + + const signed = await signer.sign(pdfBytes, {mode: Zga.Crypto.Mode.RC4_128, userpwd: "user-pw"}); + + assert.match(Buffer.from(signed).toString("latin1"), /\/Encrypt/); +}); + +// strictCrypto on the SignOption has to reach the cryptor, otherwise a caller +// asking for compliance would silently get a non-compliant encryption handler. +test("PdfSigner.sign propagates strictCrypto to the encryption step", async () => { + const pdfBytes = await minimalPdf(); + const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD, strictCrypto: true}); + + await assert.rejects( + () => signer.sign(pdfBytes, {mode: Zga.Crypto.Mode.RC4_128, userpwd: "user-pw"}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("an explicit strictCrypto on the EncryptOption wins over the SignOption", async () => { + const pdfBytes = await minimalPdf(); + const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD, strictCrypto: true}); + + const signed = await signer.sign(pdfBytes, { + mode: Zga.Crypto.Mode.RC4_128, + userpwd: "user-pw", + strictCrypto: false, + }); + + assert.match(Buffer.from(signed).toString("latin1"), /\/Encrypt/); +}); diff --git a/tests/signer.test.js b/tests/signer.test.js new file mode 100644 index 0000000..f3f1e6e --- /dev/null +++ b/tests/signer.test.js @@ -0,0 +1,52 @@ +"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); +}); + +test("RsaSigner reports sha256WithRSAEncryption as its signature algorithm OID", () => { + const {privateKey, certificate} = makeKeyCert(3072); + const signer = new Zga.RsaSigner(privateKey, certificate); + + assert.strictEqual(signer.getSignatureAlgorithmOid(), forge.pki.oids.sha256WithRSAEncryption); +}); + +test("createSigner returns an RsaSigner for an RSA key", () => { + const {privateKey, certificate} = makeKeyCert(3072); + + const signer = Zga.createSigner(privateKey, certificate); + + assert.ok(signer instanceof Zga.RsaSigner); +}); + +test("createSigner rejects an unsupported (non-RSA) key type", () => { + assert.throws( + () => Zga.createSigner({}, null), + /only RSA keys are supported/, + ); +}); diff --git a/testutil/fixtures.js b/testutil/fixtures.js new file mode 100644 index 0000000..4b887a2 --- /dev/null +++ b/testutil/fixtures.js @@ -0,0 +1,58 @@ +"use strict"; + +const Zga = require("../lib/zganode.js"); +const forge = Zga.forge; + +/** @type {number} The de-facto standard public exponent (F4). */ +const DEFAULT_EXPONENT = 0x10001; + +/** @type {Map} key cache */ +const keyCache = new Map(); + +/** + * 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 {number=} e RSA public exponent. Defaults to 65537. + * @return {{privateKey: *, publicKey: *, certificate: *}} + */ +function makeKeyCert(bits, e){ + const exponent = e === undefined ? DEFAULT_EXPONENT : e; + const cacheKey = bits + ":" + exponent; + if(keyCache.has(cacheKey)){ + return keyCache.get(cacheKey); + } + const keys = forge.pki.rsa.generateKeyPair({bits: bits, e: exponent}); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(2020, 0, 1); + cert.validity.notAfter = new Date(2030, 0, 1); + const attrs = [{name: "commonName", value: "zgapdfsigner-test"}]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.sign(keys.privateKey, forge.md.sha256.create()); + const entry = {privateKey: keys.privateKey, publicKey: keys.publicKey, certificate: cert}; + keyCache.set(cacheKey, entry); + return entry; +} + +/** + * 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 and public-exponent guards in loadP12cert without shipping + * binary fixtures. + * + * @param {number} bits RSA modulus length in bits. + * @param {string} pwd PKCS#12 password. + * @param {number=} e RSA public exponent. Defaults to 65537. + * @return {string} DER-encoded PKCS#12 as a binary string. + */ +function makeP12(bits, pwd, e){ + const kc = makeKeyCert(bits, e); + 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); +});