From 185f7607901bbb1809457f6418a1786003cab37a Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 11:53:47 +0200 Subject: [PATCH 1/5] feat: enforce CCN-STIC-221 crypto constraints on sign and encrypt --- .gitignore | 5 ++++ closure/zb-externs.js | 7 +++++ lib/zganode.d.ts | 2 ++ lib/zgapdfcryptor.js | 12 +++++++++ lib/zgapdfsigner.js | 19 +++++++++++++ package.json | 1 + tests/compliance-encryption.test.js | 42 +++++++++++++++++++++++++++++ tests/compliance-rsa.test.js | 33 +++++++++++++++++++++++ tests/core.test.js | 26 ++++++++++++++++++ testutil/fixtures.js | 39 +++++++++++++++++++++++++++ 10 files changed, 186 insertions(+) create mode 100644 tests/compliance-encryption.test.js create mode 100644 tests/compliance-rsa.test.js create mode 100644 tests/core.test.js create mode 100644 testutil/fixtures.js 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/closure/zb-externs.js b/closure/zb-externs.js index 49c123b..ad41531 100644 --- a/closure/zb-externs.js +++ b/closure/zb-externs.js @@ -95,6 +95,9 @@ 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. * + * minRsaKeyBits: Minimum accepted RSA modulus length in bits. Defaults to 3000 + * per CCN-STIC-221; signing keys shorter than this are rejected. + * * @typedef * {{ * p12cert: (Array|Uint8Array|ArrayBuffer|string|undefined), @@ -107,6 +110,7 @@ var SignDrawInfo; * signame: (string|undefined), * drawinf: (SignDrawInfo|undefined), * ltv: (number|undefined), + * minRsaKeyBits: (number|undefined), * debug: (boolean|undefined), * }} */ @@ -136,6 +140,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. * + * allowLegacyEncryption: Allow non-authorized encryption modes (RC4-40, RC4-128, AES-128). Defaults to false; only AES-256 is accepted per CCN-STIC-221 unless this is set to true. + * * @typedef * {{ * mode: Zga.Crypto.Mode, @@ -143,6 +149,7 @@ var PubKeyInfo; * userpwd: (string|undefined), * ownerpwd: (string|undefined), * pubkeys: (Array|undefined), + * allowLegacyEncryption: (boolean|undefined), * }} */ var EncryptOption; diff --git a/lib/zganode.d.ts b/lib/zganode.d.ts index 7e7699f..47ac09c 100644 --- a/lib/zganode.d.ts +++ b/lib/zganode.d.ts @@ -24,6 +24,7 @@ export type EncryptOption = { userpwd?: string; ownerpwd?: string; pubkeys?: Array; + allowLegacyEncryption?: boolean; }; export type PubKeyInfo = { c?: Array | Uint8Array | ArrayBuffer | string | forge.pki.Certificate; @@ -77,6 +78,7 @@ export type SignOption = { signame?: string; drawinf?: SignDrawInfo; ltv?: number; + minRsaKeyBits?: number; debug?: boolean; }; export type TsaServiceInfo = { diff --git a/lib/zgapdfcryptor.js b/lib/zgapdfcryptor.js index be7e4d8..88cb20f 100644 --- a/lib/zgapdfcryptor.js +++ b/lib/zgapdfcryptor.js @@ -308,6 +308,18 @@ 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. Reject weaker modes unless the caller explicitly opts into legacy + // encryption for backward compatibility. + if(!encopt.allowLegacyEncryption && 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 set allowLegacyEncryption:true to override."); + } + /** @private @type {Array|undefined} */ this.permissions = encopt.permissions; /** @private @type {string} */ diff --git a/lib/zgapdfsigner.js b/lib/zgapdfsigner.js index 7a14dbc..b5d76e7 100644 --- a/lib/zgapdfsigner.js +++ b/lib/zgapdfsigner.js @@ -680,6 +680,25 @@ z.PdfSigner = class{ })[forge.pki.oids.pkcs8ShroudedKeyBag]; _this.privateKey = keyBags[0].key; + // CCN-STIC-221: Reject shorter keys so a non-compliant certificate + // cannot be used to sign. Override the minimum via opt.minRsaKeyBits. + if(_this.privateKey && _this.privateKey.n){ + /** @type {number} */ + var minRsaKeyBits = (_this.opt && typeof _this.opt.minRsaKeyBits === "number") + ? _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(_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} */ diff --git a/package.json b/package.json index 1dfc280..d3e5052 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build": "./build.sh", "server": "node test4node.js webserver", "test": "node test4node.js ${pfxpwd}", + "test1": "node --test", "test2": "node test4node.js fetch" }, "dependencies": { diff --git a/tests/compliance-encryption.test.js b/tests/compliance-encryption.test.js new file mode 100644 index 0000000..5e34e63 --- /dev/null +++ b/tests/compliance-encryption.test.js @@ -0,0 +1,42 @@ +"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. The production change that +// makes these fail is removing the mode guard added in the PdfCryptor constructor. + +test("rejects RC4-40 encryption by default", () => { + assert.throws( + () => new Zga.PdfCryptor({mode: Mode.RC4_40, userpwd: "x"}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("rejects RC4-128 encryption by default", () => { + assert.throws( + () => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x"}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("rejects AES-128 encryption by default", () => { + assert.throws( + () => new Zga.PdfCryptor({mode: Mode.AES_128, userpwd: "x"}), + /not authorized by CCN-STIC-221/, + ); +}); + +test("accepts AES-256 encryption", () => { + assert.doesNotThrow(() => new Zga.PdfCryptor({mode: Mode.AES_256, userpwd: "x"})); +}); + +test("allows legacy modes only when allowLegacyEncryption is set", () => { + assert.doesNotThrow( + () => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x", allowLegacyEncryption: true}), + ); +}); diff --git a/tests/compliance-rsa.test.js b/tests/compliance-rsa.test.js new file mode 100644 index 0000000..fca8400 --- /dev/null +++ b/tests/compliance-rsa.test.js @@ -0,0 +1,33 @@ +"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. +// The production change that makes these fail is removing the key-length +// guard added in PdfSigner.loadP12cert. + +test("rejects an RSA key shorter than 3000 bits", () => { + const p12 = makeP12(2048, PWD); + const signer = new Zga.PdfSigner({}); + assert.throws( + () => signer.loadP12cert(p12, PWD), + /below the 3000-bit minimum/, + ); +}); + +test("accepts an RSA key of 3072 bits", () => { + const p12 = makeP12(3072, PWD); + const signer = new Zga.PdfSigner({}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); + +test("honors a custom minRsaKeyBits threshold below the default", () => { + const p12 = makeP12(2048, PWD); + const signer = new Zga.PdfSigner({minRsaKeyBits: 2048}); + 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/testutil/fixtures.js b/testutil/fixtures.js new file mode 100644 index 0000000..4e24bcd --- /dev/null +++ b/testutil/fixtures.js @@ -0,0 +1,39 @@ +"use strict"; + +const Zga = require("../lib/zganode.js"); +const forge = Zga.forge; + +/** @type {Map} in-process cache to avoid regenerating keys */ +const cache = 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. + * + * @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 cacheKey = bits + ":" + pwd; + if(cache.has(cacheKey)){ + return cache.get(cacheKey); + } + const keys = forge.pki.rsa.generateKeyPair({bits: bits, e: 0x10001}); + 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 asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], pwd, {algorithm: "3des"}); + const der = forge.asn1.toDer(asn1).getBytes(); + cache.set(cacheKey, der); + return der; +} + +module.exports = {makeP12: makeP12}; From db7b45d445cc6570b6ff4f4aa0959ecba96ddf29 Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 13:40:37 +0200 Subject: [PATCH 2/5] 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); +}); From 179fdf11e40f0733b82a37b396e08ca482ee421a Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 22:12:14 +0200 Subject: [PATCH 3/5] refactor: extract createSigner factory and add signature algorithm OID --- lib/zgapdfsigner.js | 25 +++++++++++++- test4node.js | 77 +++++++++++++++++++++++++++++++------------- tests/signer.test.js | 22 +++++++++++++ 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/lib/zgapdfsigner.js b/lib/zgapdfsigner.js index 3e5da8c..e8480ed 100644 --- a/lib/zgapdfsigner.js +++ b/lib/zgapdfsigner.js @@ -240,6 +240,13 @@ z.RsaSigner = class{ 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 @@ -252,6 +259,22 @@ z.RsaSigner = class{ } }; +/** + * 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 @@ -1053,7 +1076,7 @@ z.PdfSigner = class{ // 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()); + var signer = z.createSigner(_this.privateKey, _this.cchain.getSignCert()); // Add the signer. sha256 is what Adobe.PPKLite adbe.pkcs7.detached expects. p7.addSigner({ diff --git a/test4node.js b/test4node.js index ecd712c..38d1fad 100644 --- a/test4node.js +++ b/test4node.js @@ -197,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", "*"); @@ -241,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(){ @@ -254,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/signer.test.js b/tests/signer.test.js index 8b81bb7..f3f1e6e 100644 --- a/tests/signer.test.js +++ b/tests/signer.test.js @@ -28,3 +28,25 @@ test("RsaSigner reports SHA-256 as its digest algorithm OID", () => { 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/, + ); +}); From 162110c1fbab1c6304dc47d64fdb42575e9dcd27 Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 22:23:56 +0200 Subject: [PATCH 4/5] feat: enforce CCN-STIC-221 crypto constraints and bump to v3.0.0 --- README.md | 45 ++++++++++++++++++++++++++++------ lib/zganode.d.ts | 2 ++ package.json | 2 +- tests/compliance-rsa.test.js | 23 ++++++++++++++--- tests/sign-integration.test.js | 27 ++++++++++++++++++++ testutil/fixtures.js | 26 +++++++++++++------- 6 files changed, 105 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 62ee3e0..5ed8eff 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,44 @@ And I use this name to hope the merits from this application will be dedicated t * Sign a pdf with a timestamp from [TSA](https://github.com/zboris12/zgapdfsigner/wiki/API#note). ( :no_entry_sign:__Not__ available in web browser :sunflower:) * Enable signature's [LTV](https://github.com/zboris12/zgapdfsigner/wiki/API#note). ( :no_entry_sign:__Not__ available in web browser :sunflower:) * Set password protection to a pdf. Supported algorithms: - * 40bit RC4 Encryption - * 128bit RC4 Encryption - * 128bit AES Encryption - * 256bit AES Encryption + * 256bit AES Encryption (default; the only algorithm allowed out of the box) + * 128bit AES Encryption ( :warning: legacy, opt-in) + * 128bit RC4 Encryption ( :warning: legacy, opt-in) + * 40bit RC4 Encryption ( :warning: legacy, opt-in) * Set public-key certificate protection to a pdf. Supported algorithms are as same as the password protection. +## Cryptographic constraints (CCN-STIC-221) + +This tool enforces the cryptographic requirements of +[CCN-STIC-221](https://www.ccn-cert.cni.es/) by default. Two rules apply: + +__1. Only AES-256 encryption is allowed.__ RC4 is a stream cipher and is not +authorized, and the RC4 / AES-128 PDF security handlers derive their key with +MD5. Passing any other mode throws. Set `allowLegacyEncryption` to opt out when +you need backward compatibility with old readers: + +```js +var eopt = { + mode: Zga.Crypto.Mode.RC4_128, + allowLegacyEncryption: true, // required, otherwise this throws + userpwd: upwd, +}; +``` + +__2. Signing keys must be RSA of at least 3000 bits, with log2(e) > 16.__ +A certificate carrying a shorter key (2048 bits, for example) is rejected when +it is loaded. The standard public exponent 65537 already satisfies the exponent +rule. Lower the modulus threshold with `minRsaKeyBits` if you must: + +```js +var sopt = { + p12cert: cert, + pwd: pwd, + minRsaKeyBits: 2048, // accepts a 2048bit key; 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) Because of the [CORS](https://github.com/zboris12/zgapdfsigner/wiki/API#note) security restrictions in web browser, @@ -373,7 +404,7 @@ Set password protection to the pdf. async function protect1(pdf, upwd, opwd){ /** @type {EncryptOption} */ var eopt = { - mode: Zga.Crypto.Mode.RC4_40, + mode: Zga.Crypto.Mode.AES_256, permissions: ["modify", "annot-forms", "fill-forms", "extract", "assemble"], userpwd: upwd, ownerpwd: opwd, @@ -396,7 +427,7 @@ Set public-key certificate protection to the pdf. async function protect2(pdf, cert){ /** @type {EncryptOption} */ var eopt = { - mode: Zga.Crypto.Mode.AES_128, + mode: Zga.Crypto.Mode.AES_256, pubkeys: [{ c: cert, p: ["copy", "modify", "copy-extract", "annot-forms", "fill-forms", "extract", "assemble"], @@ -427,7 +458,7 @@ async function signAndProtect1(pdf, cert, pwd, opwd){ }; /** @type {EncryptOption} */ var eopt = { - mode: Zga.Crypto.Mode.RC4_128, + mode: Zga.Crypto.Mode.AES_256, permissions: ["modify", "annot-forms", "fill-forms", "extract", "assemble"], ownerpwd: opwd, }; diff --git a/lib/zganode.d.ts b/lib/zganode.d.ts index f9d59ce..9fa5fff 100644 --- a/lib/zganode.d.ts +++ b/lib/zganode.d.ts @@ -105,8 +105,10 @@ export declare class RsaSigner { 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/package.json b/package.json index 5b2b485..add2650 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zgapdfsigner", - "version": "2.7.6", + "version": "3.0.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", diff --git a/tests/compliance-rsa.test.js b/tests/compliance-rsa.test.js index fca8400..3aa00bb 100644 --- a/tests/compliance-rsa.test.js +++ b/tests/compliance-rsa.test.js @@ -7,9 +7,9 @@ const {makeP12} = require("../testutil/fixtures.js"); const PWD = "test-pw"; -// CCN-STIC-221: RSA modulus must be >= 3000 bits. -// The production change that makes these fail is removing the key-length -// guard added in PdfSigner.loadP12cert. +// CCN-STIC-221: RSA modulus must be >= 3000 bits and the public exponent must +// satisfy log2(e) > 16. The production change that makes these fail is removing +// the corresponding guard added in PdfSigner.loadP12cert. test("rejects an RSA key shorter than 3000 bits", () => { const p12 = makeP12(2048, PWD); @@ -31,3 +31,20 @@ test("honors a custom minRsaKeyBits threshold below the default", () => { const signer = new Zga.PdfSigner({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", () => { + const p12 = makeP12(1024, PWD, 3); + const signer = new Zga.PdfSigner({minRsaKeyBits: 1024}); + assert.throws( + () => signer.loadP12cert(p12, PWD), + /public exponent is too small/, + ); +}); + +test("accepts the standard 65537 public exponent", () => { + const p12 = makeP12(1024, PWD, 65537); + const signer = new Zga.PdfSigner({minRsaKeyBits: 1024}); + assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); +}); diff --git a/tests/sign-integration.test.js b/tests/sign-integration.test.js index 5358dc9..da0da83 100644 --- a/tests/sign-integration.test.js +++ b/tests/sign-integration.test.js @@ -32,3 +32,30 @@ test("PdfSigner.sign produces a detached PKCS#7 signature over a real PDF", asyn 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 refuses a legacy encryption mode", async () => { + const pdfBytes = await minimalPdf(); + const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD}); + + await assert.rejects( + () => signer.sign(pdfBytes, {mode: Zga.Crypto.Mode.RC4_128, userpwd: "user-pw"}), + /not authorized by CCN-STIC-221/, + ); +}); diff --git a/testutil/fixtures.js b/testutil/fixtures.js index af06864..4b887a2 100644 --- a/testutil/fixtures.js +++ b/testutil/fixtures.js @@ -3,7 +3,10 @@ const Zga = require("../lib/zganode.js"); const forge = Zga.forge; -/** @type {Map} key cache */ +/** @type {number} The de-facto standard public exponent (F4). */ +const DEFAULT_EXPONENT = 0x10001; + +/** @type {Map} key cache */ const keyCache = new Map(); /** @@ -11,13 +14,16 @@ const keyCache = new Map(); * 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){ - if(keyCache.has(bits)){ - return keyCache.get(bits); +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: 0x10001}); + const keys = forge.pki.rsa.generateKeyPair({bits: bits, e: exponent}); const cert = forge.pki.createCertificate(); cert.publicKey = keys.publicKey; cert.serialNumber = "01"; @@ -28,21 +34,23 @@ function makeKeyCert(bits){ cert.setIssuer(attrs); cert.sign(keys.privateKey, forge.md.sha256.create()); const entry = {privateKey: keys.privateKey, publicKey: keys.publicKey, certificate: cert}; - keyCache.set(bits, entry); + 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 guard in loadP12cert without shipping binary fixtures. + * 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){ - const kc = makeKeyCert(bits); +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(); } From a54166c90115041a8dc1c9fe3e2d2e39b452b842 Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 23:42:43 +0200 Subject: [PATCH 5/5] refactor: make CCN-STIC-221 enforcement opt-in via strictCrypto Replace the always-on `allowLegacyEncryption` / default-on RSA checks with a single `strictCrypto` flag on both `SignOption` and `EncryptOption`. All modes and key lengths work by default; set `strictCrypto: true` to enforce the approved-algorithms rules. Update tests, externs, type declarations, and docs accordingly. --- README.md | 75 ++++++++++++++++++----------- closure/zb-externs.js | 15 ++++-- lib/zganode.d.ts | 3 +- lib/zgapdfcryptor.js | 9 ++-- lib/zgapdfsigner.js | 37 +++++++++----- package.json | 2 +- tests/compliance-encryption.test.js | 31 ++++++------ tests/compliance-rsa.test.js | 45 ++++++++++++----- tests/sign-integration.test.js | 26 +++++++++- 9 files changed, 167 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 5ed8eff..e9af894 100644 --- a/README.md +++ b/README.md @@ -22,41 +22,62 @@ And I use this name to hope the merits from this application will be dedicated t * Sign a pdf with a timestamp from [TSA](https://github.com/zboris12/zgapdfsigner/wiki/API#note). ( :no_entry_sign:__Not__ available in web browser :sunflower:) * Enable signature's [LTV](https://github.com/zboris12/zgapdfsigner/wiki/API#note). ( :no_entry_sign:__Not__ available in web browser :sunflower:) * Set password protection to a pdf. Supported algorithms: - * 256bit AES Encryption (default; the only algorithm allowed out of the box) - * 128bit AES Encryption ( :warning: legacy, opt-in) - * 128bit RC4 Encryption ( :warning: legacy, opt-in) - * 40bit RC4 Encryption ( :warning: legacy, opt-in) + * 40bit RC4 Encryption + * 128bit RC4 Encryption + * 128bit AES Encryption + * 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/). -## Cryptographic constraints (CCN-STIC-221) +## Enforcing CCN-STIC-221 (optional) -This tool enforces the cryptographic requirements of -[CCN-STIC-221](https://www.ccn-cert.cni.es/) by default. Two rules apply: +[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.__ -__1. Only AES-256 encryption is allowed.__ RC4 is a stream cipher and is not -authorized, and the RC4 / AES-128 PDF security handlers derive their key with -MD5. Passing any other mode throws. Set `allowLegacyEncryption` to opt out when -you need backward compatibility with old readers: +When it is on: -```js -var eopt = { - mode: Zga.Crypto.Mode.RC4_128, - allowLegacyEncryption: true, // required, otherwise this throws - userpwd: upwd, -}; -``` - -__2. Signing keys must be RSA of at least 3000 bits, with log2(e) > 16.__ -A certificate carrying a shorter key (2048 bits, for example) is rejected when -it is loaded. The standard public exponent 65537 already satisfies the exponent -rule. Lower the modulus threshold with `minRsaKeyBits` if you must: +* __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, - minRsaKeyBits: 2048, // accepts a 2048bit key; not CCN-STIC-221 compliant + 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 }; ``` @@ -404,7 +425,7 @@ Set password protection to the pdf. async function protect1(pdf, upwd, opwd){ /** @type {EncryptOption} */ var eopt = { - mode: Zga.Crypto.Mode.AES_256, + mode: Zga.Crypto.Mode.RC4_40, permissions: ["modify", "annot-forms", "fill-forms", "extract", "assemble"], userpwd: upwd, ownerpwd: opwd, @@ -427,7 +448,7 @@ Set public-key certificate protection to the pdf. async function protect2(pdf, cert){ /** @type {EncryptOption} */ var eopt = { - mode: Zga.Crypto.Mode.AES_256, + mode: Zga.Crypto.Mode.AES_128, pubkeys: [{ c: cert, p: ["copy", "modify", "copy-extract", "annot-forms", "fill-forms", "extract", "assemble"], @@ -458,7 +479,7 @@ async function signAndProtect1(pdf, cert, pwd, opwd){ }; /** @type {EncryptOption} */ var eopt = { - mode: Zga.Crypto.Mode.AES_256, + mode: Zga.Crypto.Mode.RC4_128, permissions: ["modify", "annot-forms", "fill-forms", "extract", "assemble"], ownerpwd: opwd, }; diff --git a/closure/zb-externs.js b/closure/zb-externs.js index ad41531..8cfc8dd 100644 --- a/closure/zb-externs.js +++ b/closure/zb-externs.js @@ -95,8 +95,14 @@ 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. * - * minRsaKeyBits: Minimum accepted RSA modulus length in bits. Defaults to 3000 - * per CCN-STIC-221; signing keys shorter than this are rejected. + * 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 * {{ @@ -110,6 +116,7 @@ var SignDrawInfo; * signame: (string|undefined), * drawinf: (SignDrawInfo|undefined), * ltv: (number|undefined), + * strictCrypto: (boolean|undefined), * minRsaKeyBits: (number|undefined), * debug: (boolean|undefined), * }} @@ -140,7 +147,7 @@ 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. * - * allowLegacyEncryption: Allow non-authorized encryption modes (RC4-40, RC4-128, AES-128). Defaults to false; only AES-256 is accepted per CCN-STIC-221 unless this is set to true. + * 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 * {{ @@ -149,7 +156,7 @@ var PubKeyInfo; * userpwd: (string|undefined), * ownerpwd: (string|undefined), * pubkeys: (Array|undefined), - * allowLegacyEncryption: (boolean|undefined), + * strictCrypto: (boolean|undefined), * }} */ var EncryptOption; diff --git a/lib/zganode.d.ts b/lib/zganode.d.ts index 9fa5fff..347ac37 100644 --- a/lib/zganode.d.ts +++ b/lib/zganode.d.ts @@ -24,7 +24,7 @@ export type EncryptOption = { userpwd?: string; ownerpwd?: string; pubkeys?: Array; - allowLegacyEncryption?: boolean; + strictCrypto?: boolean; }; export type PubKeyInfo = { c?: Array | Uint8Array | ArrayBuffer | string | forge.pki.Certificate; @@ -78,6 +78,7 @@ export type SignOption = { signame?: string; drawinf?: SignDrawInfo; ltv?: number; + strictCrypto?: boolean; minRsaKeyBits?: number; debug?: boolean; }; diff --git a/lib/zgapdfcryptor.js b/lib/zgapdfcryptor.js index 88cb20f..fc80903 100644 --- a/lib/zgapdfcryptor.js +++ b/lib/zgapdfcryptor.js @@ -311,13 +311,12 @@ z.PdfCryptor = class{ // 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. Reject weaker modes unless the caller explicitly opts into legacy - // encryption for backward compatibility. - if(!encopt.allowLegacyEncryption && this.mode !== z.Crypto.Mode.AES_256){ + // 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 set allowLegacyEncryption:true to override."); + + "Use AES-256, or drop strictCrypto to allow legacy encryption."); } /** @private @type {Array|undefined} */ diff --git a/lib/zgapdfsigner.js b/lib/zgapdfsigner.js index e8480ed..f79e61b 100644 --- a/lib/zgapdfsigner.js +++ b/lib/zgapdfsigner.js @@ -487,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); @@ -739,21 +744,29 @@ z.PdfSigner = class{ })[forge.pki.oids.pkcs8ShroudedKeyBag]; _this.privateKey = keyBags[0].key; - // CCN-STIC-221: Reject shorter keys so a non-compliant certificate - // cannot be used to sign. Override the minimum via opt.minRsaKeyBits. - if(_this.privateKey && _this.privateKey.n){ - /** @type {number} */ - var minRsaKeyBits = (_this.opt && typeof _this.opt.minRsaKeyBits === "number") - ? _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: 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(_this.privateKey.e && _this.privateKey.e.bitLength() <= 16){ + 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."); } } diff --git a/package.json b/package.json index add2650..8a7d644 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zgapdfsigner", - "version": "3.0.0", + "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", diff --git a/tests/compliance-encryption.test.js b/tests/compliance-encryption.test.js index 5e34e63..42761d3 100644 --- a/tests/compliance-encryption.test.js +++ b/tests/compliance-encryption.test.js @@ -7,36 +7,39 @@ 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. The production change that -// makes these fail is removing the mode guard added in the PdfCryptor constructor. +// 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("rejects RC4-40 encryption by default", () => { +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"}), + () => new Zga.PdfCryptor({mode: Mode.RC4_40, userpwd: "x", strictCrypto: true}), /not authorized by CCN-STIC-221/, ); }); -test("rejects RC4-128 encryption by default", () => { +test("rejects RC4-128 encryption under strictCrypto", () => { assert.throws( - () => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x"}), + () => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x", strictCrypto: true}), /not authorized by CCN-STIC-221/, ); }); -test("rejects AES-128 encryption by default", () => { +test("rejects AES-128 encryption under strictCrypto", () => { assert.throws( - () => new Zga.PdfCryptor({mode: Mode.AES_128, userpwd: "x"}), + () => new Zga.PdfCryptor({mode: Mode.AES_128, userpwd: "x", strictCrypto: true}), /not authorized by CCN-STIC-221/, ); }); -test("accepts AES-256 encryption", () => { - assert.doesNotThrow(() => new Zga.PdfCryptor({mode: Mode.AES_256, userpwd: "x"})); -}); - -test("allows legacy modes only when allowLegacyEncryption is set", () => { +test("accepts AES-256 encryption under strictCrypto", () => { assert.doesNotThrow( - () => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x", allowLegacyEncryption: true}), + () => 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 index 3aa00bb..77991ad 100644 --- a/tests/compliance-rsa.test.js +++ b/tests/compliance-rsa.test.js @@ -8,43 +8,66 @@ 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. The production change that makes these fail is removing -// the corresponding guard added in PdfSigner.loadP12cert. +// 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("rejects an RSA key shorter than 3000 bits", () => { +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", () => { +test("accepts an RSA key of 3072 bits under strictCrypto", () => { const p12 = makeP12(3072, PWD); - const signer = new Zga.PdfSigner({}); + const signer = new Zga.PdfSigner({strictCrypto: true}); assert.doesNotThrow(() => signer.loadP12cert(p12, PWD)); }); -test("honors a custom minRsaKeyBits threshold below the default", () => { - const p12 = makeP12(2048, 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", () => { +test("rejects an RSA public exponent with log2(e) <= 16 under strictCrypto", () => { const p12 = makeP12(1024, PWD, 3); - const signer = new Zga.PdfSigner({minRsaKeyBits: 1024}); + const signer = new Zga.PdfSigner({strictCrypto: true, minRsaKeyBits: 1024}); assert.throws( () => signer.loadP12cert(p12, PWD), /public exponent is too small/, ); }); -test("accepts the standard 65537 public exponent", () => { - const p12 = makeP12(1024, PWD, 65537); +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/sign-integration.test.js b/tests/sign-integration.test.js index da0da83..0f5c0fe 100644 --- a/tests/sign-integration.test.js +++ b/tests/sign-integration.test.js @@ -50,12 +50,36 @@ test("PdfSigner.sign encrypts the output when an AES-256 EncryptOption is given" assert.match(dump, /adbe\.pkcs7\.detached/, "still carries the detached signature"); }); -test("PdfSigner.sign refuses a legacy encryption mode", async () => { +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/); +});