From 162110c1fbab1c6304dc47d64fdb42575e9dcd27 Mon Sep 17 00:00:00 2001 From: Marcos Sanz Latorre Date: Tue, 4 Aug 2026 22:23:56 +0200 Subject: [PATCH] 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(); }