@certysign/sdk 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +994 -55
- package/package.json +15 -6
- package/src/index.js +55 -20
- package/src/lib/CertificateResource.js +24 -3
- package/src/lib/DashboardResource.js +91 -0
- package/src/lib/DocumentHasher.js +123 -0
- package/src/lib/HashSigningResource.js +220 -0
- package/src/lib/HttpClient.js +1 -1
- package/src/lib/SignatureEmbedder.js +723 -0
- package/src/lib/SigningSessionResource.js +183 -0
- package/examples/certificate-flow.js +0 -198
- package/examples/dha-integration.js +0 -208
- package/examples/nhif-batch-sign.js +0 -216
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SignatureEmbedder — local signature embedding
|
|
3
|
+
*
|
|
4
|
+
* Embeds CMS/PKCS#7 signatures into documents on the subscriber's system.
|
|
5
|
+
* Documents NEVER leave the subscriber's infrastructure.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - PDF (PAdES-style with cryptographic PKCS#7 embedding) — single or multi-recipient visual stamps
|
|
9
|
+
* - XML (XMLDSig enveloped signature)
|
|
10
|
+
* - JSON (JWS-like detached signature)
|
|
11
|
+
*
|
|
12
|
+
* Visual stamp format (matches the CertySign platform):
|
|
13
|
+
* ┌──────────────────────────────────────────┐
|
|
14
|
+
* │ Digitally signed by: user@example.com │
|
|
15
|
+
* │ Date: 2026-03-13T11:07:28.927Z │
|
|
16
|
+
* │ Certificate: 9EF9C8E88478FC08C6759942 │
|
|
17
|
+
* │ Standard: PAdES Baseline B-B │
|
|
18
|
+
* └──────────────────────────────────────────┘
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
|
|
25
|
+
// ── Constants ──
|
|
26
|
+
const STAMP_FONT_SIZE = 7;
|
|
27
|
+
const STAMP_LINE_HEIGHT = 10;
|
|
28
|
+
const STAMP_PADDING_X = 8;
|
|
29
|
+
const STAMP_PADDING_Y = 8;
|
|
30
|
+
const STAMP_WIDTH = 260;
|
|
31
|
+
const STAMP_MARGIN = 6; // gap between multiple stamps
|
|
32
|
+
// Placeholder size for PKCS#7 signature content (8192 bytes = 16384 hex chars)
|
|
33
|
+
const SIG_PLACEHOLDER_LENGTH = 16384;
|
|
34
|
+
|
|
35
|
+
class SignatureEmbedder {
|
|
36
|
+
/**
|
|
37
|
+
* Embed one or more digital signatures into a PDF document with
|
|
38
|
+
* proper PKCS#7/CMS cryptographic embedding (PAdES compliant).
|
|
39
|
+
*
|
|
40
|
+
* Creates a PDF /Sig dictionary with /ByteRange and /Contents
|
|
41
|
+
* that Adobe Reader, Foxit, and other PDF readers recognise as
|
|
42
|
+
* a valid digital signature.
|
|
43
|
+
*
|
|
44
|
+
* **Recommended:** Pass a `signCallback` function — the embedder will:
|
|
45
|
+
* 1. Prepare the PDF with visual stamps and an empty /Sig placeholder
|
|
46
|
+
* 2. Compute the SHA-256 hash of the ByteRange regions
|
|
47
|
+
* 3. Call your `signCallback(hash)` to get the HSM signature
|
|
48
|
+
* 4. Build a valid PKCS#7 and patch it into /Contents
|
|
49
|
+
*
|
|
50
|
+
* This produces a cryptographically valid PAdES signature that
|
|
51
|
+
* Adobe Reader, Foxit, and other PDF readers can verify.
|
|
52
|
+
*
|
|
53
|
+
* @param {Buffer} pdfBuffer - Original unsigned PDF
|
|
54
|
+
* @param {EmbedPdfOptions} options
|
|
55
|
+
* @returns {Promise<Buffer>} - Signed PDF buffer
|
|
56
|
+
*/
|
|
57
|
+
async embedInPdf(pdfBuffer, options) {
|
|
58
|
+
if (!pdfBuffer) throw new Error('embedInPdf: pdfBuffer is required');
|
|
59
|
+
|
|
60
|
+
let PDFDocument, PDFName, PDFHexString, PDFString, PDFDict, PDFArray, PDFNumber, rgb, StandardFonts;
|
|
61
|
+
try {
|
|
62
|
+
const pdfLib = require('pdf-lib');
|
|
63
|
+
PDFDocument = pdfLib.PDFDocument;
|
|
64
|
+
PDFName = pdfLib.PDFName;
|
|
65
|
+
PDFHexString = pdfLib.PDFHexString;
|
|
66
|
+
PDFString = pdfLib.PDFString;
|
|
67
|
+
PDFDict = pdfLib.PDFDict;
|
|
68
|
+
PDFArray = pdfLib.PDFArray;
|
|
69
|
+
PDFNumber = pdfLib.PDFNumber;
|
|
70
|
+
rgb = pdfLib.rgb;
|
|
71
|
+
StandardFonts = pdfLib.StandardFonts;
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'pdf-lib is required for PDF signature embedding. Install it: npm install pdf-lib'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let forge;
|
|
79
|
+
try {
|
|
80
|
+
forge = require('node-forge');
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'node-forge is required for cryptographic PDF signing. Install it: npm install node-forge'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pdfDoc = await PDFDocument.load(pdfBuffer, { ignoreEncryption: true });
|
|
88
|
+
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
89
|
+
const pages = pdfDoc.getPages();
|
|
90
|
+
|
|
91
|
+
// ── Normalise to a single signature entry ──
|
|
92
|
+
// For PAdES we process one signature at a time (each needs its own ByteRange)
|
|
93
|
+
const entry = options.signatures ? options.signatures[0] : options;
|
|
94
|
+
if (!entry) throw new Error('embedInPdf: at least one signature entry is required');
|
|
95
|
+
|
|
96
|
+
const signerEmail = entry.signerEmail || entry.recipientEmail || entry.signerName || 'CertySign';
|
|
97
|
+
const certSerial = entry.certSerialNumber || '';
|
|
98
|
+
const signDate = entry.timestamp ? new Date(entry.timestamp) :
|
|
99
|
+
entry.signedAt ? new Date(entry.signedAt) : new Date();
|
|
100
|
+
const reason = entry.reason || 'Digital signature';
|
|
101
|
+
const location = entry.location || '';
|
|
102
|
+
const standard = entry.standard || 'PAdES Baseline B-B';
|
|
103
|
+
const certPem = entry.certificate || null;
|
|
104
|
+
const chainPem = entry.chain || null;
|
|
105
|
+
const signCallback = entry.signCallback || options.signCallback || null;
|
|
106
|
+
const precomputedSig = entry.signature || null;
|
|
107
|
+
|
|
108
|
+
if (!signCallback && !precomputedSig) {
|
|
109
|
+
throw new Error('embedInPdf: either signCallback or signature is required');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Determine target page ──
|
|
113
|
+
const pos = entry.signaturePosition || {};
|
|
114
|
+
const pageIdx = pos.page
|
|
115
|
+
? Math.min(pos.page - 1, pages.length - 1)
|
|
116
|
+
: pages.length - 1;
|
|
117
|
+
const sigPage = pages[pageIdx];
|
|
118
|
+
|
|
119
|
+
// 4 text lines = height
|
|
120
|
+
const stampHeight = (4 * STAMP_LINE_HEIGHT) + (2 * STAMP_PADDING_Y);
|
|
121
|
+
const stampWidth = pos.width || STAMP_WIDTH;
|
|
122
|
+
const x = pos.x != null ? pos.x : 20;
|
|
123
|
+
const y = pos.y != null ? pos.y : 20;
|
|
124
|
+
|
|
125
|
+
// ── Draw stamp box ──
|
|
126
|
+
sigPage.drawRectangle({
|
|
127
|
+
x, y,
|
|
128
|
+
width: stampWidth,
|
|
129
|
+
height: stampHeight,
|
|
130
|
+
borderColor: rgb(0.4, 0.4, 0.4),
|
|
131
|
+
borderWidth: 0.75,
|
|
132
|
+
color: rgb(0.97, 0.97, 0.97),
|
|
133
|
+
opacity: 0.95
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── Draw text lines matching CertySign platform format ──
|
|
137
|
+
const textLines = [
|
|
138
|
+
`Digitally signed by: ${signerEmail}`,
|
|
139
|
+
`Date: ${signDate.toISOString()}`,
|
|
140
|
+
`Certificate: ${certSerial}`,
|
|
141
|
+
`Standard: ${standard}`
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
let textY = y + stampHeight - STAMP_PADDING_Y - STAMP_FONT_SIZE;
|
|
145
|
+
for (const line of textLines) {
|
|
146
|
+
sigPage.drawText(line, {
|
|
147
|
+
x: x + STAMP_PADDING_X,
|
|
148
|
+
y: textY,
|
|
149
|
+
size: STAMP_FONT_SIZE,
|
|
150
|
+
font,
|
|
151
|
+
color: rgb(0.4, 0.4, 0.4),
|
|
152
|
+
maxWidth: stampWidth - (2 * STAMP_PADDING_X)
|
|
153
|
+
});
|
|
154
|
+
textY -= STAMP_LINE_HEIGHT;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Create the /Sig dictionary with empty placeholder ──
|
|
158
|
+
const sigDict = pdfDoc.context.obj({
|
|
159
|
+
Type: 'Sig',
|
|
160
|
+
Filter: 'Adobe.PPKLite',
|
|
161
|
+
SubFilter: 'adbe.pkcs7.detached',
|
|
162
|
+
// Large placeholder numbers ensure enough bytes for patching
|
|
163
|
+
ByteRange: [0, 9999999999, 9999999999, 9999999999],
|
|
164
|
+
Contents: PDFHexString.of('0'.repeat(SIG_PLACEHOLDER_LENGTH)),
|
|
165
|
+
Reason: PDFString.of(reason),
|
|
166
|
+
M: PDFString.of(_pdfDate(signDate)),
|
|
167
|
+
Name: PDFString.of(signerEmail),
|
|
168
|
+
Location: location ? PDFString.of(location) : undefined,
|
|
169
|
+
ContactInfo: PDFString.of(signerEmail)
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const sigDictRef = pdfDoc.context.register(sigDict);
|
|
173
|
+
|
|
174
|
+
// ── Create widget annotation for the visual stamp ──
|
|
175
|
+
const widgetDict = pdfDoc.context.obj({
|
|
176
|
+
Type: 'Annot',
|
|
177
|
+
Subtype: 'Widget',
|
|
178
|
+
FT: 'Sig',
|
|
179
|
+
Rect: [x, y, x + stampWidth, y + stampHeight],
|
|
180
|
+
V: sigDictRef,
|
|
181
|
+
T: PDFString.of('CertySign-Sig-0'),
|
|
182
|
+
F: 4, // Print flag
|
|
183
|
+
P: pages[pageIdx].ref,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const widgetRef = pdfDoc.context.register(widgetDict);
|
|
187
|
+
|
|
188
|
+
// Add widget to page Annots array
|
|
189
|
+
const pageDict = pages[pageIdx].node;
|
|
190
|
+
let annots = pageDict.lookup(PDFName.of('Annots'));
|
|
191
|
+
if (!annots || !(annots instanceof PDFArray)) {
|
|
192
|
+
annots = pdfDoc.context.obj([]);
|
|
193
|
+
pageDict.set(PDFName.of('Annots'), annots);
|
|
194
|
+
}
|
|
195
|
+
annots.push(widgetRef);
|
|
196
|
+
|
|
197
|
+
// Add to AcroForm
|
|
198
|
+
let acroForm = pdfDoc.catalog.lookup(PDFName.of('AcroForm'));
|
|
199
|
+
if (!acroForm || !(acroForm instanceof PDFDict)) {
|
|
200
|
+
acroForm = pdfDoc.context.obj({
|
|
201
|
+
Fields: [],
|
|
202
|
+
SigFlags: 3 // SignaturesExist | AppendOnly
|
|
203
|
+
});
|
|
204
|
+
pdfDoc.catalog.set(PDFName.of('AcroForm'), acroForm);
|
|
205
|
+
} else {
|
|
206
|
+
acroForm.set(PDFName.of('SigFlags'), PDFNumber.of(3));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let fields = acroForm.lookup(PDFName.of('Fields'));
|
|
210
|
+
if (!fields || !(fields instanceof PDFArray)) {
|
|
211
|
+
fields = pdfDoc.context.obj([]);
|
|
212
|
+
acroForm.set(PDFName.of('Fields'), fields);
|
|
213
|
+
}
|
|
214
|
+
fields.push(widgetRef);
|
|
215
|
+
|
|
216
|
+
pdfDoc.setProducer('CertySign Trust Services');
|
|
217
|
+
pdfDoc.setModificationDate(new Date());
|
|
218
|
+
|
|
219
|
+
// ── Phase 1: Save PDF with empty signature placeholder ──
|
|
220
|
+
let pdfBytes = Buffer.from(await pdfDoc.save({ useObjectStreams: false }));
|
|
221
|
+
|
|
222
|
+
// ── Phase 2: Patch ByteRange and compute hash of signed regions ──
|
|
223
|
+
const pdfStr = pdfBytes.toString('latin1');
|
|
224
|
+
const placeholderHex = '0'.repeat(SIG_PLACEHOLDER_LENGTH);
|
|
225
|
+
const placeholderIdx = pdfStr.indexOf(placeholderHex);
|
|
226
|
+
if (placeholderIdx === -1) {
|
|
227
|
+
throw new Error('embedInPdf: could not find signature placeholder in saved PDF');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const contentsStart = placeholderIdx - 1; // byte offset of '<'
|
|
231
|
+
const contentsEnd = placeholderIdx + SIG_PLACEHOLDER_LENGTH + 1; // byte after '>'
|
|
232
|
+
const totalLen = pdfBytes.length;
|
|
233
|
+
|
|
234
|
+
const br0 = 0;
|
|
235
|
+
const br1 = contentsStart;
|
|
236
|
+
const br2 = contentsEnd;
|
|
237
|
+
const br3 = totalLen - contentsEnd;
|
|
238
|
+
|
|
239
|
+
// Patch ByteRange values in-place
|
|
240
|
+
const searchStart = Math.max(0, placeholderIdx - 500);
|
|
241
|
+
const searchRegion = pdfStr.substring(searchStart, placeholderIdx);
|
|
242
|
+
const brRelIdx = searchRegion.lastIndexOf('/ByteRange');
|
|
243
|
+
if (brRelIdx !== -1) {
|
|
244
|
+
const absIdx = searchStart + brRelIdx;
|
|
245
|
+
const closeBracket = pdfStr.indexOf(']', absIdx);
|
|
246
|
+
if (closeBracket !== -1) {
|
|
247
|
+
const oldBrLen = closeBracket + 1 - absIdx;
|
|
248
|
+
const newBrValue = `/ByteRange [${br0} ${br1} ${br2} ${br3}]`;
|
|
249
|
+
const newBr = newBrValue.padEnd(oldBrLen, ' ');
|
|
250
|
+
pdfBytes.write(newBr, absIdx, oldBrLen, 'latin1');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Phase 3: Compute hash of the ByteRange regions (everything except /Contents hex) ──
|
|
255
|
+
const byteRangeHash = crypto.createHash('sha256');
|
|
256
|
+
byteRangeHash.update(pdfBytes.subarray(br0, br0 + br1)); // before <Contents>
|
|
257
|
+
byteRangeHash.update(pdfBytes.subarray(br2, br2 + br3)); // after <Contents>
|
|
258
|
+
const hashHex = byteRangeHash.digest('hex');
|
|
259
|
+
|
|
260
|
+
// ── Phase 4: Get the signature ──
|
|
261
|
+
let rawSigBase64;
|
|
262
|
+
let sigCertPem = certPem;
|
|
263
|
+
let sigChainPem = chainPem;
|
|
264
|
+
|
|
265
|
+
if (signCallback) {
|
|
266
|
+
// Call the HSM to sign the ByteRange hash — produces a valid PAdES signature
|
|
267
|
+
const signResult = await signCallback(hashHex);
|
|
268
|
+
rawSigBase64 = signResult.data?.signature || signResult.signature;
|
|
269
|
+
sigCertPem = sigCertPem || signResult.data?.certificate || signResult.certificate;
|
|
270
|
+
sigChainPem = sigChainPem || signResult.data?.chain || signResult.chain;
|
|
271
|
+
} else {
|
|
272
|
+
// Legacy: use pre-computed signature (won't validate against ByteRange)
|
|
273
|
+
rawSigBase64 = precomputedSig;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Phase 5: Build PKCS#7 and patch into /Contents ──
|
|
277
|
+
const p7 = _buildPkcs7(forge, rawSigBase64, sigCertPem, sigChainPem, signDate, {
|
|
278
|
+
signerEmail, reason, location, certSerial
|
|
279
|
+
});
|
|
280
|
+
const p7Der = forge.asn1.toDer(p7).getBytes();
|
|
281
|
+
const p7Hex = Buffer.from(p7Der, 'binary').toString('hex');
|
|
282
|
+
|
|
283
|
+
if (p7Hex.length > SIG_PLACEHOLDER_LENGTH) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`PKCS#7 signature too large (${p7Hex.length} hex chars > ${SIG_PLACEHOLDER_LENGTH} placeholder). ` +
|
|
286
|
+
'Increase SIG_PLACEHOLDER_LENGTH.'
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Write PKCS#7 hex into the /Contents placeholder
|
|
291
|
+
const paddedHex = p7Hex.padEnd(SIG_PLACEHOLDER_LENGTH, '0');
|
|
292
|
+
pdfBytes.write(paddedHex, placeholderIdx, SIG_PLACEHOLDER_LENGTH, 'latin1');
|
|
293
|
+
|
|
294
|
+
return pdfBytes;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Embed digital signatures into an XML document.
|
|
299
|
+
*
|
|
300
|
+
* Uses XMLDSig enveloped signature format.
|
|
301
|
+
* Supports multiple signatures — each signer gets their own <ds:Signature> element.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} xmlString - Original XML content
|
|
304
|
+
* @param {EmbedXmlOptions} options
|
|
305
|
+
* @returns {string} - Signed XML string
|
|
306
|
+
*/
|
|
307
|
+
embedInXml(xmlString, options) {
|
|
308
|
+
if (!xmlString) throw new Error('embedInXml: xmlString is required');
|
|
309
|
+
|
|
310
|
+
const sigEntries = options.signatures ? options.signatures : [options];
|
|
311
|
+
|
|
312
|
+
let result = xmlString;
|
|
313
|
+
|
|
314
|
+
for (let idx = 0; idx < sigEntries.length; idx++) {
|
|
315
|
+
const entry = sigEntries[idx];
|
|
316
|
+
const {
|
|
317
|
+
signature,
|
|
318
|
+
certificate,
|
|
319
|
+
documentHash,
|
|
320
|
+
hashAlgorithm = 'sha256',
|
|
321
|
+
certSerialNumber = '',
|
|
322
|
+
timestamp
|
|
323
|
+
} = entry;
|
|
324
|
+
|
|
325
|
+
if (!signature) throw new Error(`embedInXml: signature is required (entry ${idx})`);
|
|
326
|
+
|
|
327
|
+
const signerEmail = entry.signerEmail || entry.recipientEmail || entry.signerName || 'CertySign';
|
|
328
|
+
const standard = entry.standard || 'PAdES Baseline B-B';
|
|
329
|
+
const signDate = timestamp ? new Date(timestamp) : entry.signedAt ? new Date(entry.signedAt) : new Date();
|
|
330
|
+
|
|
331
|
+
const algorithmUri = {
|
|
332
|
+
sha256: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
|
|
333
|
+
sha384: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384',
|
|
334
|
+
sha512: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512'
|
|
335
|
+
}[hashAlgorithm] || 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
|
|
336
|
+
|
|
337
|
+
const digestUri = {
|
|
338
|
+
sha256: 'http://www.w3.org/2001/04/xmlenc#sha256',
|
|
339
|
+
sha384: 'http://www.w3.org/2001/04/xmldsig-more#sha384',
|
|
340
|
+
sha512: 'http://www.w3.org/2001/04/xmlenc#sha512'
|
|
341
|
+
}[hashAlgorithm] || 'http://www.w3.org/2001/04/xmlenc#sha256';
|
|
342
|
+
|
|
343
|
+
const certValue = certificate
|
|
344
|
+
? certificate.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
345
|
+
.replace(/-----END CERTIFICATE-----/g, '')
|
|
346
|
+
.replace(/\s/g, '')
|
|
347
|
+
: '';
|
|
348
|
+
|
|
349
|
+
const signatureXml = `
|
|
350
|
+
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="CertySign-Signature-${idx}">
|
|
351
|
+
<ds:SignedInfo>
|
|
352
|
+
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
|
353
|
+
<ds:SignatureMethod Algorithm="${algorithmUri}"/>
|
|
354
|
+
<ds:Reference URI="">
|
|
355
|
+
<ds:Transforms>
|
|
356
|
+
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
|
|
357
|
+
</ds:Transforms>
|
|
358
|
+
<ds:DigestMethod Algorithm="${digestUri}"/>
|
|
359
|
+
<ds:DigestValue>${documentHash ? Buffer.from(documentHash, 'hex').toString('base64') : ''}</ds:DigestValue>
|
|
360
|
+
</ds:Reference>
|
|
361
|
+
</ds:SignedInfo>
|
|
362
|
+
<ds:SignatureValue>${signature}</ds:SignatureValue>
|
|
363
|
+
<ds:KeyInfo>
|
|
364
|
+
<ds:X509Data>
|
|
365
|
+
<ds:X509Certificate>${certValue}</ds:X509Certificate>
|
|
366
|
+
<ds:X509SerialNumber>${certSerialNumber}</ds:X509SerialNumber>
|
|
367
|
+
</ds:X509Data>
|
|
368
|
+
</ds:KeyInfo>
|
|
369
|
+
<ds:Object>
|
|
370
|
+
<SignatureProperties xmlns="urn:certysign:signature:1.0">
|
|
371
|
+
<SignerEmail>${_xmlEscape(signerEmail)}</SignerEmail>
|
|
372
|
+
<Timestamp>${signDate.toISOString()}</Timestamp>
|
|
373
|
+
<CertSerial>${_xmlEscape(certSerialNumber)}</CertSerial>
|
|
374
|
+
<Standard>${_xmlEscape(standard)}</Standard>
|
|
375
|
+
<Provider>CertySign</Provider>
|
|
376
|
+
</SignatureProperties>
|
|
377
|
+
</ds:Object>
|
|
378
|
+
</ds:Signature>`;
|
|
379
|
+
|
|
380
|
+
// Insert before closing root tag
|
|
381
|
+
const closingTagMatch = result.match(/<\/([^\s>]+)\s*>\s*$/);
|
|
382
|
+
if (closingTagMatch) {
|
|
383
|
+
const insertPos = result.lastIndexOf(closingTagMatch[0]);
|
|
384
|
+
result = result.substring(0, insertPos) + signatureXml + '\n' + closingTagMatch[0];
|
|
385
|
+
} else {
|
|
386
|
+
result += signatureXml;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Create a signed JSON envelope with one or more signatures.
|
|
395
|
+
*
|
|
396
|
+
* Supports multiple signers — each signer's signature is stored
|
|
397
|
+
* in the `signatures[]` array alongside the original data.
|
|
398
|
+
*
|
|
399
|
+
* @param {Object|string} jsonData - Original JSON content
|
|
400
|
+
* @param {EmbedJsonOptions} options
|
|
401
|
+
* @returns {Object} - Signed JSON envelope
|
|
402
|
+
*/
|
|
403
|
+
embedInJson(jsonData, options) {
|
|
404
|
+
const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
|
|
405
|
+
const sigEntries = options.signatures ? options.signatures : [options];
|
|
406
|
+
|
|
407
|
+
const signaturesArray = sigEntries.map((entry, idx) => {
|
|
408
|
+
if (!entry.signature) throw new Error(`embedInJson: signature is required (entry ${idx})`);
|
|
409
|
+
|
|
410
|
+
const signerEmail = entry.signerEmail || entry.recipientEmail || entry.signerName || 'CertySign';
|
|
411
|
+
const signDate = entry.timestamp ? new Date(entry.timestamp) : entry.signedAt ? new Date(entry.signedAt) : new Date();
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
value: entry.signature,
|
|
415
|
+
algorithm: entry.algorithm || 'SHA256withRSA',
|
|
416
|
+
hashAlgorithm: entry.hashAlgorithm || 'sha256',
|
|
417
|
+
documentHash: entry.documentHash || '',
|
|
418
|
+
timestamp: signDate.toISOString(),
|
|
419
|
+
signer: {
|
|
420
|
+
email: signerEmail,
|
|
421
|
+
name: entry.recipientName || entry.signerName || signerEmail,
|
|
422
|
+
certSerialNumber: entry.certSerialNumber || ''
|
|
423
|
+
},
|
|
424
|
+
certificate: entry.certificate || null,
|
|
425
|
+
chain: entry.chain || null,
|
|
426
|
+
standard: entry.standard || 'PAdES Baseline B-B'
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
data,
|
|
432
|
+
signatures: signaturesArray,
|
|
433
|
+
metadata: {
|
|
434
|
+
provider: 'CertySign Trust Services',
|
|
435
|
+
version: '2.0.0',
|
|
436
|
+
signatureCount: signaturesArray.length,
|
|
437
|
+
signedAt: new Date().toISOString()
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
function _xmlEscape(str) {
|
|
446
|
+
return String(str)
|
|
447
|
+
.replace(/&/g, '&')
|
|
448
|
+
.replace(/</g, '<')
|
|
449
|
+
.replace(/>/g, '>')
|
|
450
|
+
.replace(/"/g, '"')
|
|
451
|
+
.replace(/'/g, ''');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build a PKCS#7 SignedData ASN.1 structure wrapping the raw RSA signature
|
|
456
|
+
* and the signer certificate (+ optional chain).
|
|
457
|
+
*
|
|
458
|
+
* The HSM already produced the raw RSA signature over the document hash.
|
|
459
|
+
* We wrap it into a CMS SignedData container so PDF readers recognise it.
|
|
460
|
+
*/
|
|
461
|
+
function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta) {
|
|
462
|
+
const rawSigBytes = forge.util.decode64(rawSigBase64);
|
|
463
|
+
|
|
464
|
+
// Parse the signing certificate
|
|
465
|
+
const certs = [];
|
|
466
|
+
if (certPem) {
|
|
467
|
+
certs.push(forge.pki.certificateFromPem(certPem));
|
|
468
|
+
}
|
|
469
|
+
if (chainPem) {
|
|
470
|
+
// Chain may contain multiple PEM blocks
|
|
471
|
+
const chainBlocks = chainPem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
|
|
472
|
+
for (const block of chainBlocks) {
|
|
473
|
+
try { certs.push(forge.pki.certificateFromPem(block)); } catch { /* skip invalid */ }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const signerCert = certs[0] || null;
|
|
478
|
+
|
|
479
|
+
// ── Build ASN.1 SignedData manually ──
|
|
480
|
+
// OID: 1.2.840.113549.1.7.2 (signedData)
|
|
481
|
+
const contentType = forge.asn1.oidToDer('1.2.840.113549.1.7.2').getBytes();
|
|
482
|
+
// OID: 1.2.840.113549.1.7.1 (data)
|
|
483
|
+
const dataType = forge.asn1.oidToDer('1.2.840.113549.1.7.1').getBytes();
|
|
484
|
+
// OID: 2.16.840.1.101.3.4.2.1 (sha-256)
|
|
485
|
+
const sha256Oid = forge.asn1.oidToDer('2.16.840.1.101.3.4.2.1').getBytes();
|
|
486
|
+
|
|
487
|
+
// Create certificate SET
|
|
488
|
+
const certSet = [];
|
|
489
|
+
for (const cert of certs) {
|
|
490
|
+
certSet.push(forge.asn1.fromDer(forge.asn1.toDer(forge.pki.certificateToAsn1(cert))));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Build SignerInfo
|
|
494
|
+
const signerInfoSets = [];
|
|
495
|
+
if (signerCert) {
|
|
496
|
+
const issuerSeq = forge.asn1.fromDer(
|
|
497
|
+
forge.asn1.toDer(forge.pki.distinguishedNameToAsn1(signerCert.issuer))
|
|
498
|
+
);
|
|
499
|
+
const serialInt = forge.asn1.create(
|
|
500
|
+
forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false,
|
|
501
|
+
forge.util.hexToBytes(signerCert.serialNumber)
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Authenticated attributes
|
|
505
|
+
const signingTime = forge.asn1.create(
|
|
506
|
+
forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
|
|
507
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
508
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false,
|
|
509
|
+
forge.asn1.oidToDer('1.2.840.113549.1.9.5').getBytes()), // signingTime OID
|
|
510
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
|
|
511
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.UTCTIME, false,
|
|
512
|
+
_asn1UtcTime(signDate))
|
|
513
|
+
])
|
|
514
|
+
])
|
|
515
|
+
]
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const signerInfo = forge.asn1.create(
|
|
519
|
+
forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
520
|
+
// version
|
|
521
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false,
|
|
522
|
+
forge.util.hexToBytes('01')),
|
|
523
|
+
// issuerAndSerialNumber
|
|
524
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
525
|
+
issuerSeq,
|
|
526
|
+
serialInt
|
|
527
|
+
]),
|
|
528
|
+
// digestAlgorithm
|
|
529
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
530
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, sha256Oid),
|
|
531
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.NULL, false, '')
|
|
532
|
+
]),
|
|
533
|
+
// signatureAlgorithm (rsaEncryption)
|
|
534
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
535
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false,
|
|
536
|
+
forge.asn1.oidToDer('1.2.840.113549.1.1.1').getBytes()),
|
|
537
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.NULL, false, '')
|
|
538
|
+
]),
|
|
539
|
+
// signature
|
|
540
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false,
|
|
541
|
+
rawSigBytes)
|
|
542
|
+
]
|
|
543
|
+
);
|
|
544
|
+
signerInfoSets.push(signerInfo);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// SignedData SEQUENCE
|
|
548
|
+
const signedData = forge.asn1.create(
|
|
549
|
+
forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
550
|
+
// version
|
|
551
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false,
|
|
552
|
+
forge.util.hexToBytes('01')),
|
|
553
|
+
// digestAlgorithms SET
|
|
554
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
|
|
555
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
556
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, sha256Oid),
|
|
557
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.NULL, false, '')
|
|
558
|
+
])
|
|
559
|
+
]),
|
|
560
|
+
// contentInfo (empty data — detached signature)
|
|
561
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
562
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, dataType)
|
|
563
|
+
]),
|
|
564
|
+
// certificates [0] IMPLICIT
|
|
565
|
+
...(certSet.length > 0 ? [
|
|
566
|
+
forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, certSet)
|
|
567
|
+
] : []),
|
|
568
|
+
// signerInfos SET
|
|
569
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, signerInfoSets)
|
|
570
|
+
]
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// Wrap in ContentInfo
|
|
574
|
+
const contentInfo = forge.asn1.create(
|
|
575
|
+
forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
|
|
576
|
+
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, contentType),
|
|
577
|
+
forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [signedData])
|
|
578
|
+
]
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
return contentInfo;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Convert a Date to ASN.1 UTCTime format (YYMMDDHHmmssZ)
|
|
586
|
+
*/
|
|
587
|
+
function _asn1UtcTime(date) {
|
|
588
|
+
const pad = n => String(n).padStart(2, '0');
|
|
589
|
+
const y = date.getUTCFullYear() % 100;
|
|
590
|
+
return `${pad(y)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
|
|
591
|
+
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Convert a Date to PDF date format (D:YYYYMMDDHHmmss+00'00')
|
|
596
|
+
*/
|
|
597
|
+
function _pdfDate(date) {
|
|
598
|
+
const pad = n => String(n).padStart(2, '0');
|
|
599
|
+
return `D:${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
|
|
600
|
+
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}+00'00'`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Patch the placeholder /Contents hex string in the saved PDF with actual PKCS#7 DER bytes.
|
|
605
|
+
* Searches for the placeholder pattern and replaces it with the real signature.
|
|
606
|
+
*/
|
|
607
|
+
function _patchSignatureContents(pdfBytes, p7Hex) {
|
|
608
|
+
// The placeholder is a hex string of null bytes (00) of length SIG_PLACEHOLDER_LENGTH
|
|
609
|
+
const placeholderHex = '0'.repeat(SIG_PLACEHOLDER_LENGTH);
|
|
610
|
+
const pdfStr = pdfBytes.toString('latin1');
|
|
611
|
+
|
|
612
|
+
// Find the placeholder in the PDF — it appears inside angle brackets: <0000...0000>
|
|
613
|
+
const placeholderIdx = pdfStr.indexOf(placeholderHex);
|
|
614
|
+
if (placeholderIdx === -1) {
|
|
615
|
+
return pdfBytes;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Pad the actual signature hex to fill the full placeholder length
|
|
619
|
+
const paddedHex = p7Hex.padEnd(SIG_PLACEHOLDER_LENGTH, '0');
|
|
620
|
+
|
|
621
|
+
// Replace in a mutable buffer
|
|
622
|
+
const result = Buffer.from(pdfBytes);
|
|
623
|
+
result.write(paddedHex, placeholderIdx, SIG_PLACEHOLDER_LENGTH, 'latin1');
|
|
624
|
+
|
|
625
|
+
// Calculate actual ByteRange values
|
|
626
|
+
// '<' is at placeholderIdx - 1, '>' is at placeholderIdx + SIG_PLACEHOLDER_LENGTH
|
|
627
|
+
const contentsStart = placeholderIdx - 1; // byte offset of '<'
|
|
628
|
+
const contentsEnd = placeholderIdx + SIG_PLACEHOLDER_LENGTH + 1; // byte after '>'
|
|
629
|
+
const totalLen = result.length;
|
|
630
|
+
|
|
631
|
+
// ByteRange: [before_sig_start, before_sig_len, after_sig_start, after_sig_len]
|
|
632
|
+
const br0 = 0;
|
|
633
|
+
const br1 = contentsStart;
|
|
634
|
+
const br2 = contentsEnd;
|
|
635
|
+
const br3 = totalLen - contentsEnd;
|
|
636
|
+
|
|
637
|
+
// Find /ByteRange [...] near the signature and patch it
|
|
638
|
+
const searchStart = Math.max(0, placeholderIdx - 500);
|
|
639
|
+
const searchRegion = pdfStr.substring(searchStart, placeholderIdx);
|
|
640
|
+
const brRelIdx = searchRegion.lastIndexOf('/ByteRange');
|
|
641
|
+
if (brRelIdx !== -1) {
|
|
642
|
+
const absIdx = searchStart + brRelIdx;
|
|
643
|
+
const closeBracket = pdfStr.indexOf(']', absIdx);
|
|
644
|
+
if (closeBracket !== -1) {
|
|
645
|
+
const oldBrLen = closeBracket + 1 - absIdx;
|
|
646
|
+
const newBrValue = `/ByteRange [${br0} ${br1} ${br2} ${br3}]`;
|
|
647
|
+
// Pad with spaces to exactly match original length — prevents shifting bytes
|
|
648
|
+
const newBr = newBrValue.padEnd(oldBrLen, ' ');
|
|
649
|
+
result.write(newBr, absIdx, oldBrLen, 'latin1');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* @typedef {Object} SignatureEntry
|
|
658
|
+
* @property {string} signature - Base64 CMS/PKCS#7 from CertySign
|
|
659
|
+
* @property {string} [signerEmail] - Signer email (used for visual stamp)
|
|
660
|
+
* @property {string} [recipientEmail] - Alias for signerEmail
|
|
661
|
+
* @property {string} [signerName] - Fallback signer name
|
|
662
|
+
* @property {string} [recipientName] - Signer display name
|
|
663
|
+
* @property {string} [certSerialNumber] - Certificate serial number
|
|
664
|
+
* @property {string} [certificate] - PEM certificate
|
|
665
|
+
* @property {string} [chain] - PEM chain
|
|
666
|
+
* @property {string} [documentHash] - Hex document hash
|
|
667
|
+
* @property {string} [hashAlgorithm]
|
|
668
|
+
* @property {string} [algorithm]
|
|
669
|
+
* @property {string} [reason]
|
|
670
|
+
* @property {string} [location]
|
|
671
|
+
* @property {string} [standard] - Default: 'PAdES Baseline B-B'
|
|
672
|
+
* @property {string} [timestamp]
|
|
673
|
+
* @property {string} [signedAt]
|
|
674
|
+
* @property {{ page?: number, x?: number, y?: number, width?: number }} [signaturePosition]
|
|
675
|
+
*/
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* @typedef {Object} EmbedPdfOptions
|
|
679
|
+
* @property {string} [signature] - Single signature (use this OR signatures[])
|
|
680
|
+
* @property {SignatureEntry[]} [signatures] - Multiple signatures for multi-recipient
|
|
681
|
+
* @property {string} [signerEmail]
|
|
682
|
+
* @property {string} [certificate]
|
|
683
|
+
* @property {string} [chain]
|
|
684
|
+
* @property {string} [reason]
|
|
685
|
+
* @property {string} [signerName]
|
|
686
|
+
* @property {string} [location]
|
|
687
|
+
* @property {string} [certSerialNumber]
|
|
688
|
+
* @property {string} [documentHash]
|
|
689
|
+
* @property {string} [hashAlgorithm]
|
|
690
|
+
* @property {string} [algorithm]
|
|
691
|
+
* @property {string} [standard]
|
|
692
|
+
* @property {string} [timestamp]
|
|
693
|
+
* @property {{ page?: number, x?: number, y?: number, width?: number }} [signaturePosition]
|
|
694
|
+
*/
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* @typedef {Object} EmbedXmlOptions
|
|
698
|
+
* @property {string} [signature]
|
|
699
|
+
* @property {SignatureEntry[]} [signatures]
|
|
700
|
+
* @property {string} [certificate]
|
|
701
|
+
* @property {string} [chain]
|
|
702
|
+
* @property {string} [documentHash]
|
|
703
|
+
* @property {string} [hashAlgorithm]
|
|
704
|
+
* @property {string} [signerEmail]
|
|
705
|
+
* @property {string} [certSerialNumber]
|
|
706
|
+
* @property {string} [timestamp]
|
|
707
|
+
*/
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* @typedef {Object} EmbedJsonOptions
|
|
711
|
+
* @property {string} [signature]
|
|
712
|
+
* @property {SignatureEntry[]} [signatures]
|
|
713
|
+
* @property {string} [certificate]
|
|
714
|
+
* @property {string} [chain]
|
|
715
|
+
* @property {string} [documentHash]
|
|
716
|
+
* @property {string} [hashAlgorithm]
|
|
717
|
+
* @property {string} [algorithm]
|
|
718
|
+
* @property {string} [signerEmail]
|
|
719
|
+
* @property {string} [certSerialNumber]
|
|
720
|
+
* @property {string} [timestamp]
|
|
721
|
+
*/
|
|
722
|
+
|
|
723
|
+
module.exports = { SignatureEmbedder };
|