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.
pull/14/head
Marcos Sanz Latorre 2026-08-04 23:42:43 +02:00
parent 162110c1fb
commit a54166c901
9 changed files with 167 additions and 76 deletions

View File

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

View File

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

3
lib/zganode.d.ts vendored
View File

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

View File

@ -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<string>|undefined} */

View File

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

View File

@ -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",

View File

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

View File

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

View File

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