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};