feat: enforce CCN-STIC-221 crypto constraints and bump to v3.0.0

pull/14/head
Marcos Sanz Latorre 2026-08-04 22:23:56 +02:00
parent 179fdf11e4
commit 162110c1fb
6 changed files with 105 additions and 20 deletions

View File

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

2
lib/zganode.d.ts vendored
View File

@ -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<number> | Uint8Array | ArrayBuffer | string, cypopt?: EncryptOption): Promise<Uint8Array>;

View File

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

View File

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

View File

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

View File

@ -3,7 +3,10 @@
const Zga = require("../lib/zganode.js");
const forge = Zga.forge;
/** @type {Map<number, {privateKey: *, publicKey: *, certificate: *}>} key cache */
/** @type {number} The de-facto standard public exponent (F4). */
const DEFAULT_EXPONENT = 0x10001;
/** @type {Map<string, {privateKey: *, publicKey: *, certificate: *}>} 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();
}