pull/14/merge
Marcos Sanz 2026-08-04 23:44:46 +02:00 committed by GitHub
commit 5fcf0f98cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 739 additions and 30 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

@ -28,6 +28,58 @@ And I use this name to hope the merits from this application will be dedicated t
* 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/).
## Enforcing CCN-STIC-221 (optional)
[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.__
When it is on:
* __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,
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
};
```
## About signing with [TSA](https://github.com/zboris12/zgapdfsigner/wiki/API#note) and [LTV](https://github.com/zboris12/zgapdfsigner/wiki/API#note)

View File

@ -95,6 +95,15 @@ 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.
*
* 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
* {{
* p12cert: (Array<number>|Uint8Array|ArrayBuffer|string|undefined),
@ -107,6 +116,8 @@ var SignDrawInfo;
* signame: (string|undefined),
* drawinf: (SignDrawInfo|undefined),
* ltv: (number|undefined),
* strictCrypto: (boolean|undefined),
* minRsaKeyBits: (number|undefined),
* debug: (boolean|undefined),
* }}
*/
@ -136,6 +147,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.
*
* 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
* {{
* mode: Zga.Crypto.Mode,
@ -143,6 +156,7 @@ var PubKeyInfo;
* userpwd: (string|undefined),
* ownerpwd: (string|undefined),
* pubkeys: (Array<PubKeyInfo>|undefined),
* strictCrypto: (boolean|undefined),
* }}
*/
var EncryptOption;

12
lib/zganode.d.ts vendored
View File

@ -24,6 +24,7 @@ export type EncryptOption = {
userpwd?: string;
ownerpwd?: string;
pubkeys?: Array<PubKeyInfo>;
strictCrypto?: boolean;
};
export type PubKeyInfo = {
c?: Array<number> | Uint8Array | ArrayBuffer | string | forge.pki.Certificate;
@ -77,6 +78,8 @@ export type SignOption = {
signame?: string;
drawinf?: SignDrawInfo;
ltv?: number;
strictCrypto?: boolean;
minRsaKeyBits?: number;
debug?: boolean;
};
export type TsaServiceInfo = {
@ -98,6 +101,15 @@ export declare class PdfCryptor {
encryptPdf(pdf: PDFLib.PDFDocument | Array<number> | Uint8Array | ArrayBuffer | string, ref?: PDFLib.PDFRef): Promise<PDFLib.PDFDocument>;
encryptObject(num: number, val: PDFLib.PDFObject): void;
}
export declare class RsaSigner {
constructor(privateKey: forge.pki.rsa.PrivateKey, certificate: forge.pki.Certificate);
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

@ -308,6 +308,17 @@ 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.
// 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 drop strictCrypto to allow legacy encryption.");
}
/** @private @type {Array<string>|undefined} */
this.permissions = encopt.permissions;
/** @private @type {string} */

View File

@ -216,6 +216,65 @@ z.NewRefMap = class extends Map{
/** @type {z.NewRefMap<string, z.NewRef>} */
z.newRefs = new z.NewRefMap();
/**
* Crypto-agility seam: a Signer produces the raw signature bytes for the
* PKCS#7 SignerInfo, keeping the signature algorithm out of the PDF assembly.
* RsaSigner captures the current RSA + SHA-256 (PKCS#1 v1.5) behavior.
*/
z.RsaSigner = class{
/**
* @param {forge.pki.rsa.PrivateKey} privateKey
* @param {forge_cert} certificate
*/
constructor(privateKey, certificate){
/** @type {forge.pki.rsa.PrivateKey} */
this.privateKey = privateKey;
/** @type {forge_cert} */
this.certificate = certificate;
}
/**
* @return {string} the digest algorithm OID used for the PKCS#7 SignerInfo
*/
getDigestAlgorithmOid(){
return forge.pki.oids.sha256;
}
/**
* @return {string} the signature algorithm OID for the PKCS#7 SignerInfo
*/
getSignatureAlgorithmOid(){
return forge.pki.oids.sha256WithRSAEncryption;
}
/**
* @param {string} data
* @return {string} raw signature bytes
*/
sign(data){
/** @type {forge.md.digest} */
var md = forge.md.sha256.create();
md.update(data);
return this.privateKey.sign(md);
}
};
/**
* Select a Signer implementation for the given key. Today only RSA keys are
* supported; this factory is the single place to extend the seam with ECDSA
* or post-quantum signers without touching the PDF assembly.
*
* @param {forge.pki.rsa.PrivateKey} privateKey
* @param {forge_cert} certificate
* @return {z.RsaSigner}
*/
z.createSigner = function(privateKey, certificate){
if(privateKey && privateKey.n && privateKey.e){
return new z.RsaSigner(privateKey, certificate);
}
throw new Error("Unsupported signing key type; only RSA keys are supported.");
};
z.PdfSigner = class{
/**
* @param {SignOption} signopt
@ -428,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);
@ -680,6 +744,33 @@ z.PdfSigner = class{
})[forge.pki.oids.pkcs8ShroudedKeyBag];
_this.privateKey = keyBags[0].key;
// 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(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.");
}
}
/** @type {Array<forge_cert>} */
var certs = [];
/** @type {number} */
@ -993,11 +1084,18 @@ z.PdfSigner = class{
p7.addCertificate(a_cert);
});
// Add a sha256 signer. That's what Adobe.PPKLite adbe.pkcs7.detached expects.
// Build the signer for this operation. The Signer owns the signature
// algorithm choice (digest + key), keeping it out of the PKCS#7 assembly
// so it can later be swapped without touching this code. node-forge still
// performs the RSA signing via the supplied key (shallow seam).
/** @type {z.RsaSigner} */
var signer = z.createSigner(_this.privateKey, _this.cchain.getSignCert());
// Add the signer. sha256 is what Adobe.PPKLite adbe.pkcs7.detached expects.
p7.addSigner({
key: _this.privateKey,
certificate: _this.cchain.getSignCert(),
digestAlgorithm: forge.pki.oids.sha256,
certificate: signer.certificate,
digestAlgorithm: signer.getDigestAlgorithmOid(),
authenticatedAttributes: [
{
"type": forge.pki.oids.contentType,

View File

@ -1,6 +1,6 @@
{
"name": "zgapdfsigner",
"version": "2.7.6",
"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",
@ -33,8 +33,10 @@
"scripts": {
"build": "./build.sh",
"server": "node test4node.js webserver",
"test": "node test4node.js ${pfxpwd}",
"test2": "node test4node.js fetch"
"test": "node --test",
"test:fixtures": "node testutil/make-demo.js",
"test:manual": "node test4node.js",
"test:fetch": "node test4node.js fetch"
},
"dependencies": {
"follow-redirects": "^1.16.0",

View File

@ -156,7 +156,10 @@ async function main1(angle){
var imgPath = m_path.join(__dirname, workpath+"_test.png");
/** @type {string} */
var fontPath = m_path.join(__dirname, workpath+"_test.ttf");
// var fontPath = Zga.PDFLib.StandardFonts.CourierBold;
if(!m_fs.existsSync(fontPath)){
// Fall back to a built-in font when no custom TTF has been supplied.
fontPath = Zga.PDFLib.StandardFonts.CourierBold;
}
if(process.argv.length > 3){
pfxPath = process.argv[2];
@ -171,7 +174,12 @@ async function main1(angle){
}
if(pfxPath){
await sign_protect(pdfPath, pfxPath, ps, 1, imgPath, "あいうえおあいうえおか\r\n\nThis is a test of text!\n", fontPath);
// Standard fonts are WinAnsi-encoded and cannot render Japanese text.
if(Zga.PDFLib.isStandardFont(fontPath)){
await sign_protect(pdfPath, pfxPath, ps, 1, imgPath, "This is a test of text!\n", fontPath);
}else{
await sign_protect(pdfPath, pfxPath, ps, 1, imgPath, "あいうえおあいうえおか\r\n\nThis is a test of text!\n", fontPath);
}
if(Zga.PDFLib.isStandardFont(fontPath)){
pdfPath = await sign_protect(pdfPath, pfxPath, ps, 2, imgPath, "This is an another test of text!\n", fontPath);
pdfPath = await sign_protect(pdfPath, pfxPath, ps, 0, undefined, "This is a test for same font!\n", fontPath);
@ -189,23 +197,38 @@ async function main1(angle){
// test urlFetch
async function main2(){
/** @type {Uint8Array} */
var u8arr = await Zga.urlFetch("http://localhost:8080", {
"headers": {
"testzb": "pineapple"
}
});
// /** @type {string} */
// var str = btoa(Zga.u8arrToRaw(u8arr));
/** @type {TextDecoder} */
var txtdec = new TextDecoder("utf-8");
/** @type {string} */
var str = txtdec.decode(u8arr);
console.log(str);
// Boot a throwaway server on an ephemeral port so this test is self
// contained and cannot collide with an already running `npm run server`.
/** @type {http.Server} */
var srv = await startWebserver(0);
/** @type {number} */
var port = srv.address().port;
try{
/** @type {Uint8Array} */
var u8arr = await Zga.urlFetch("http://localhost:"+port, {
"headers": {
"testzb": "pineapple"
}
});
// /** @type {string} */
// var str = btoa(Zga.u8arrToRaw(u8arr));
/** @type {TextDecoder} */
var txtdec = new TextDecoder("utf-8");
/** @type {string} */
var str = txtdec.decode(u8arr);
console.log(str);
}finally{
srv.close();
}
}
function webserver(){
require("http").createServer(function(req, res){
/**
* @param {number} port Port to listen on. 0 picks a free ephemeral port.
* @return {Promise<http.Server>} The listening server.
*/
function startWebserver(port){
/** @type {http.Server} */
var srv = require("http").createServer(function(req, res){
if(req.method == "GET"){
if(req.headers["testzb"]){
res.setHeader("Access-Control-Allow-Origin", "*");
@ -233,7 +256,14 @@ function webserver(){
res.statusMessage = "CORS OK";
res.end();
}
}).listen(8080, function(){console.log("Server http://localhost:8080")});
});
return new Promise(function(resolve, reject){
srv.once("error", reject);
srv.listen(port, function(){
console.log("Server http://localhost:"+srv.address().port);
resolve(srv);
});
});
}
async function main(){
@ -246,10 +276,21 @@ async function main(){
}
}
if(process.argv[2] == "webserver"){
webserver();
}else if(process.argv[2] == "fetch"){
main2();
}else{
main();
/**
* @param {Promise<*>} p
*/
function run(p){
p.catch(function(err){
console.error(err);
process.exitCode = 1;
});
}
if(process.argv[2] == "webserver"){
// Fixed port, because test.html expects the server on 8080.
run(startWebserver(8080));
}else if(process.argv[2] == "fetch"){
run(main2());
}else{
run(main());
}

View File

@ -0,0 +1,45 @@
"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. 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("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", strictCrypto: true}),
/not authorized by CCN-STIC-221/,
);
});
test("rejects RC4-128 encryption under strictCrypto", () => {
assert.throws(
() => new Zga.PdfCryptor({mode: Mode.RC4_128, userpwd: "x", strictCrypto: true}),
/not authorized by CCN-STIC-221/,
);
});
test("rejects AES-128 encryption under strictCrypto", () => {
assert.throws(
() => new Zga.PdfCryptor({mode: Mode.AES_128, userpwd: "x", strictCrypto: true}),
/not authorized by CCN-STIC-221/,
);
});
test("accepts AES-256 encryption under strictCrypto", () => {
assert.doesNotThrow(
() => new Zga.PdfCryptor({mode: Mode.AES_256, userpwd: "x", strictCrypto: true}),
);
});

View File

@ -0,0 +1,73 @@
"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 and the public exponent must
// 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("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 under strictCrypto", () => {
const p12 = makeP12(3072, PWD);
const signer = new Zga.PdfSigner({strictCrypto: true});
assert.doesNotThrow(() => signer.loadP12cert(p12, 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 under strictCrypto", () => {
const p12 = makeP12(1024, PWD, 3);
const signer = new Zga.PdfSigner({strictCrypto: true, minRsaKeyBits: 1024});
assert.throws(
() => signer.loadP12cert(p12, PWD),
/public exponent is too small/,
);
});
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));
});

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

View File

@ -0,0 +1,85 @@
"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";
/**
* Build a minimal one-page PDF with pdf-lib.
* @return {Promise<Uint8Array>}
*/
async function minimalPdf(){
const doc = await Zga.PDFLib.PDFDocument.create();
doc.addPage([300, 300]);
return doc.save();
}
// End-to-end safety net for the crypto-agility refactor: signing a real PDF
// must keep producing a valid detached PKCS#7 signature dictionary.
test("PdfSigner.sign produces a detached PKCS#7 signature over a real PDF", async () => {
const pdfBytes = await minimalPdf();
const signer = new Zga.PdfSigner({p12cert: makeP12(3072, PWD), pwd: PWD});
const signed = await signer.sign(pdfBytes);
const dump = Buffer.from(signed).toString("latin1");
assert.ok(signed instanceof Uint8Array, "returns a Uint8Array");
assert.ok(signed.length > pdfBytes.length, "signed output is larger than the input");
assert.match(dump, /adbe\.pkcs7\.detached/, "uses the detached PKCS#7 SubFilter");
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 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/);
});

52
tests/signer.test.js Normal file
View File

@ -0,0 +1,52 @@
"use strict";
const {test} = require("node:test");
const assert = require("node:assert");
const Zga = require("../lib/zganode.js");
const {makeKeyCert} = require("../testutil/fixtures.js");
const forge = Zga.forge;
// Crypto-agility seam (Update 4): a Signer abstraction decouples the signature
// algorithm from the PDF/PKCS#7 assembly. RsaSigner captures the current
// RSA + SHA-256 behavior behind that interface.
test("RsaSigner.sign produces a SHA-256 RSA signature that verifies against the certificate public key", () => {
const {privateKey, certificate} = makeKeyCert(3072);
const signer = new Zga.RsaSigner(privateKey, certificate);
const data = "hello zgapdfsigner";
const signature = signer.sign(data);
const md = forge.md.sha256.create();
md.update(data);
assert.strictEqual(certificate.publicKey.verify(md.digest().bytes(), signature), true);
});
test("RsaSigner reports SHA-256 as its digest algorithm OID", () => {
const {privateKey, certificate} = makeKeyCert(3072);
const signer = new Zga.RsaSigner(privateKey, certificate);
assert.strictEqual(signer.getDigestAlgorithmOid(), forge.pki.oids.sha256);
});
test("RsaSigner reports sha256WithRSAEncryption as its signature algorithm OID", () => {
const {privateKey, certificate} = makeKeyCert(3072);
const signer = new Zga.RsaSigner(privateKey, certificate);
assert.strictEqual(signer.getSignatureAlgorithmOid(), forge.pki.oids.sha256WithRSAEncryption);
});
test("createSigner returns an RsaSigner for an RSA key", () => {
const {privateKey, certificate} = makeKeyCert(3072);
const signer = Zga.createSigner(privateKey, certificate);
assert.ok(signer instanceof Zga.RsaSigner);
});
test("createSigner rejects an unsupported (non-RSA) key type", () => {
assert.throws(
() => Zga.createSigner({}, null),
/only RSA keys are supported/,
);
});

58
testutil/fixtures.js Normal file
View File

@ -0,0 +1,58 @@
"use strict";
const Zga = require("../lib/zganode.js");
const forge = Zga.forge;
/** @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();
/**
* Generate (and cache) a self-signed RSA key pair + certificate of the given
* 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, 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: exponent});
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 entry = {privateKey: keys.privateKey, publicKey: keys.publicKey, certificate: cert};
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 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, e){
const kc = makeKeyCert(bits, e);
const asn1 = forge.pkcs12.toPkcs12Asn1(kc.privateKey, [kc.certificate], pwd, {algorithm: "3des"});
return forge.asn1.toDer(asn1).getBytes();
}
module.exports = {makeKeyCert: makeKeyCert, makeP12: makeP12};

135
testutil/make-demo.js Normal file
View File

@ -0,0 +1,135 @@
"use strict";
/**
* Generate the demo fixtures that test4node.js reads from the (gitignored)
* `test/` directory: a source PDF for every rotation the manual demo walks
* through, a stamp image, and a self-signed PKCS#12 certificate.
*
* Usage: node testutil/make-demo.js [pfxPassword]
*/
const m_fs = require("fs");
const m_path = require("path");
const m_zlib = require("zlib");
const Zga = require("../lib/zganode.js");
const {makeP12} = require("./fixtures.js");
/** @type {string} Output directory, matching the `workpath` of test4node.js. */
const outDir = m_path.join(__dirname, "..", "test");
/** @type {Array<number>} Page rotations the manual demo iterates over. */
const ROTATIONS = [0, 90, 180, 270];
/** @type {string} */
const DEFAULT_PWD = "zgatest";
/** @type {number} CCN-STIC-221 requires at least 3000 bits. */
const RSA_BITS = 3072;
/**
* Build a one-page demo PDF with the given page rotation.
*
* @param {number} angle Page rotation in degrees.
* @return {Promise<Uint8Array>}
*/
async function makePdf(angle){
const doc = await Zga.PDFLib.PDFDocument.create();
const font = await doc.embedFont(Zga.PDFLib.StandardFonts.Helvetica);
const page = doc.addPage([595, 842]);
page.drawText("zgapdfsigner demo document", {x: 60, y: 760, size: 18, font: font});
page.drawText("Page rotation: " + angle + " degrees", {x: 60, y: 730, size: 12, font: font});
page.drawText("Generated by testutil/make-demo.js", {x: 60, y: 710, size: 10, font: font});
if(angle){
page.setRotation(Zga.PDFLib.degrees(angle));
}
return doc.save();
}
/**
* CRC-32 as specified by the PNG format.
*
* @param {Buffer} buf
* @return {number}
*/
function crc32(buf){
let crc = 0xffffffff;
for(let i = 0; i < buf.length; i++){
crc ^= buf[i];
for(let bit = 0; bit < 8; bit++){
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* Wrap a payload into a length-prefixed, CRC-suffixed PNG chunk.
*
* @param {string} type Four-character chunk type.
* @param {Buffer} data
* @return {Buffer}
*/
function pngChunk(type, data){
const head = Buffer.alloc(4);
head.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const tail = Buffer.alloc(4);
tail.writeUInt32BE(crc32(body), 0);
return Buffer.concat([head, body, tail]);
}
/**
* Build a small solid-color PNG, used as the signature stamp image.
*
* @return {Buffer}
*/
function makePng(){
const size = 64;
const stride = 1 + size * 3;
const raw = Buffer.alloc(size * stride);
for(let y = 0; y < size; y++){
const row = y * stride;
raw[row] = 0; // filter type: none
for(let x = 0; x < size; x++){
const px = row + 1 + x * 3;
const edge = x < 4 || y < 4 || x >= size - 4 || y >= size - 4;
raw[px] = edge ? 0x1f : 0x8a;
raw[px + 1] = edge ? 0x6f : 0xc8;
raw[px + 2] = edge ? 0xd5 : 0xf0;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", m_zlib.deflateSync(raw)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
async function main(){
const pwd = process.argv[2] || DEFAULT_PWD;
m_fs.mkdirSync(outDir, {recursive: true});
for(const angle of ROTATIONS){
const name = "_test" + (angle ? "_" + angle : "") + ".pdf";
m_fs.writeFileSync(m_path.join(outDir, name), await makePdf(angle));
console.log("Wrote " + m_path.join(outDir, name));
}
const pngPath = m_path.join(outDir, "_test.png");
m_fs.writeFileSync(pngPath, makePng());
console.log("Wrote " + pngPath);
const pfxPath = m_path.join(outDir, "_test.pfx");
m_fs.writeFileSync(pfxPath, Buffer.from(makeP12(RSA_BITS, pwd), "latin1"));
console.log("Wrote " + pfxPath + " (password: " + pwd + ")");
console.log("\nRun the manual demo with: npm run test:manual -- " + pwd);
}
main().catch(function(err){
console.error(err);
process.exit(1);
});