feat: enforce CCN-STIC-221 crypto constraints on sign and encrypt

pull/14/head
Marcos Sanz Latorre 2026-08-04 11:53:47 +02:00
parent bbd49bdac0
commit 185f760790
10 changed files with 186 additions and 0 deletions

5
.gitignore vendored
View File

@ -52,6 +52,11 @@ typings/
# Optional npm cache directory
.npm
# pnpm
.pnpm-store/
pnpm-lock.yaml
pnpm-debug.log*
# Optional eslint cache
.eslintcache

View File

@ -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<number>|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<PubKeyInfo>|undefined),
* allowLegacyEncryption: (boolean|undefined),
* }}
*/
var EncryptOption;

2
lib/zganode.d.ts vendored
View File

@ -24,6 +24,7 @@ export type EncryptOption = {
userpwd?: string;
ownerpwd?: string;
pubkeys?: Array<PubKeyInfo>;
allowLegacyEncryption?: boolean;
};
export type PubKeyInfo = {
c?: Array<number> | 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 = {

View File

@ -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<string>|undefined} */
this.permissions = encopt.permissions;
/** @private @type {string} */

View File

@ -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<forge_cert>} */
var certs = [];
/** @type {number} */

View File

@ -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": {

View File

@ -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}),
);
});

View File

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

26
tests/core.test.js Normal file
View File

@ -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"]);
});

39
testutil/fixtures.js Normal file
View File

@ -0,0 +1,39 @@
"use strict";
const Zga = require("../lib/zganode.js");
const forge = Zga.forge;
/** @type {Map<string, string>} 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};