refactor: extract RsaSigner for crypto-agility seam
parent
185f760790
commit
db7b45d445
|
|
@ -100,6 +100,13 @@ export declare class PdfCryptor {
|
||||||
encryptPdf(pdf: PDFLib.PDFDocument | Array<number> | Uint8Array | ArrayBuffer | string, ref?: PDFLib.PDFRef): Promise<PDFLib.PDFDocument>;
|
encryptPdf(pdf: PDFLib.PDFDocument | Array<number> | Uint8Array | ArrayBuffer | string, ref?: PDFLib.PDFRef): Promise<PDFLib.PDFDocument>;
|
||||||
encryptObject(num: number, val: PDFLib.PDFObject): void;
|
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;
|
||||||
|
sign(data: string): string;
|
||||||
|
}
|
||||||
export declare class PdfSigner {
|
export declare class PdfSigner {
|
||||||
constructor(signopt: SignOption);
|
constructor(signopt: SignOption);
|
||||||
sign(pdf: PDFLib.PDFDocument | Array<number> | Uint8Array | ArrayBuffer | string, cypopt?: EncryptOption): Promise<Uint8Array>;
|
sign(pdf: PDFLib.PDFDocument | Array<number> | Uint8Array | ArrayBuffer | string, cypopt?: EncryptOption): Promise<Uint8Array>;
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,42 @@ z.NewRefMap = class extends Map{
|
||||||
/** @type {z.NewRefMap<string, z.NewRef>} */
|
/** @type {z.NewRefMap<string, z.NewRef>} */
|
||||||
z.newRefs = new z.NewRefMap();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
z.PdfSigner = class{
|
z.PdfSigner = class{
|
||||||
/**
|
/**
|
||||||
* @param {SignOption} signopt
|
* @param {SignOption} signopt
|
||||||
|
|
@ -1012,11 +1048,18 @@ z.PdfSigner = class{
|
||||||
p7.addCertificate(a_cert);
|
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 = new z.RsaSigner(_this.privateKey, _this.cchain.getSignCert());
|
||||||
|
|
||||||
|
// Add the signer. sha256 is what Adobe.PPKLite adbe.pkcs7.detached expects.
|
||||||
p7.addSigner({
|
p7.addSigner({
|
||||||
key: _this.privateKey,
|
key: _this.privateKey,
|
||||||
certificate: _this.cchain.getSignCert(),
|
certificate: signer.certificate,
|
||||||
digestAlgorithm: forge.pki.oids.sha256,
|
digestAlgorithm: signer.getDigestAlgorithmOid(),
|
||||||
authenticatedAttributes: [
|
authenticatedAttributes: [
|
||||||
{
|
{
|
||||||
"type": forge.pki.oids.contentType,
|
"type": forge.pki.oids.contentType,
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "./build.sh",
|
"build": "./build.sh",
|
||||||
"server": "node test4node.js webserver",
|
"server": "node test4node.js webserver",
|
||||||
"test": "node test4node.js ${pfxpwd}",
|
"test": "node --test",
|
||||||
"test1": "node --test",
|
"test:fixtures": "node testutil/make-demo.js",
|
||||||
"test2": "node test4node.js fetch"
|
"test:manual": "node test4node.js",
|
||||||
|
"test:fetch": "node test4node.js fetch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
|
|
|
||||||
12
test4node.js
12
test4node.js
|
|
@ -156,7 +156,10 @@ async function main1(angle){
|
||||||
var imgPath = m_path.join(__dirname, workpath+"_test.png");
|
var imgPath = m_path.join(__dirname, workpath+"_test.png");
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
var fontPath = m_path.join(__dirname, workpath+"_test.ttf");
|
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){
|
if(process.argv.length > 3){
|
||||||
pfxPath = process.argv[2];
|
pfxPath = process.argv[2];
|
||||||
|
|
@ -171,7 +174,12 @@ async function main1(angle){
|
||||||
}
|
}
|
||||||
|
|
||||||
if(pfxPath){
|
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)){
|
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, 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);
|
pdfPath = await sign_protect(pdfPath, pfxPath, ps, 0, undefined, "This is a test for same font!\n", fontPath);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"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");
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"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);
|
||||||
|
});
|
||||||
|
|
@ -3,22 +3,19 @@
|
||||||
const Zga = require("../lib/zganode.js");
|
const Zga = require("../lib/zganode.js");
|
||||||
const forge = Zga.forge;
|
const forge = Zga.forge;
|
||||||
|
|
||||||
/** @type {Map<string, string>} in-process cache to avoid regenerating keys */
|
/** @type {Map<number, {privateKey: *, publicKey: *, certificate: *}>} key cache */
|
||||||
const cache = new Map();
|
const keyCache = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a self-signed PKCS#12 (returned as a DER binary string) carrying an
|
* Generate (and cache) a self-signed RSA key pair + certificate of the given
|
||||||
* RSA key of the given modulus length. Used to exercise the CCN-STIC-221
|
* modulus length, as raw node-forge objects.
|
||||||
* key-length guard in loadP12cert without shipping binary fixtures.
|
|
||||||
*
|
*
|
||||||
* @param {number} bits RSA modulus length in bits.
|
* @param {number} bits RSA modulus length in bits.
|
||||||
* @param {string} pwd PKCS#12 password.
|
* @return {{privateKey: *, publicKey: *, certificate: *}}
|
||||||
* @return {string} DER-encoded PKCS#12 as a binary string.
|
|
||||||
*/
|
*/
|
||||||
function makeP12(bits, pwd){
|
function makeKeyCert(bits){
|
||||||
const cacheKey = bits + ":" + pwd;
|
if(keyCache.has(bits)){
|
||||||
if(cache.has(cacheKey)){
|
return keyCache.get(bits);
|
||||||
return cache.get(cacheKey);
|
|
||||||
}
|
}
|
||||||
const keys = forge.pki.rsa.generateKeyPair({bits: bits, e: 0x10001});
|
const keys = forge.pki.rsa.generateKeyPair({bits: bits, e: 0x10001});
|
||||||
const cert = forge.pki.createCertificate();
|
const cert = forge.pki.createCertificate();
|
||||||
|
|
@ -30,10 +27,24 @@ function makeP12(bits, pwd){
|
||||||
cert.setSubject(attrs);
|
cert.setSubject(attrs);
|
||||||
cert.setIssuer(attrs);
|
cert.setIssuer(attrs);
|
||||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||||
const asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], pwd, {algorithm: "3des"});
|
const entry = {privateKey: keys.privateKey, publicKey: keys.publicKey, certificate: cert};
|
||||||
const der = forge.asn1.toDer(asn1).getBytes();
|
keyCache.set(bits, entry);
|
||||||
cache.set(cacheKey, der);
|
return entry;
|
||||||
return der;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {makeP12: makeP12};
|
/**
|
||||||
|
* 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 kc = makeKeyCert(bits);
|
||||||
|
const asn1 = forge.pkcs12.toPkcs12Asn1(kc.privateKey, [kc.certificate], pwd, {algorithm: "3des"});
|
||||||
|
return forge.asn1.toDer(asn1).getBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {makeKeyCert: makeKeyCert, makeP12: makeP12};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue