@certysign/sdk 2.0.0 → 2.2.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.
@@ -5,7 +5,7 @@
5
5
  * Documents NEVER leave the subscriber's infrastructure.
6
6
  *
7
7
  * Supports:
8
- * - PDF (PAdES-style with pdf-lib) — single or multi-recipient visual stamps
8
+ * - PDF (PAdES-style with cryptographic PKCS#7 embedding) — single or multi-recipient visual stamps
9
9
  * - XML (XMLDSig enveloped signature)
10
10
  * - JSON (JWS-like detached signature)
11
11
  *
@@ -29,23 +29,35 @@ const STAMP_PADDING_X = 8;
29
29
  const STAMP_PADDING_Y = 8;
30
30
  const STAMP_WIDTH = 260;
31
31
  const STAMP_MARGIN = 6; // gap between multiple stamps
32
+ // Placeholder size for PKCS#7 signature content (16384 bytes = 32768 hex chars)
33
+ // Sized for PAdES-T: main PKCS#7 (~4KB) + TSA timestamp token (~6-8KB hybrid)
34
+ const SIG_PLACEHOLDER_LENGTH = 32768;
32
35
 
33
36
  class SignatureEmbedder {
34
37
  /**
35
- * Embed one or more digital signatures into a PDF document.
38
+ * @param {Object} [opts]
39
+ * @param {string} [opts.tsaUrl] - TSA Service URL for PAdES-T timestamps
40
+ */
41
+ constructor(opts = {}) {
42
+ this.tsaUrl = opts.tsaUrl || null;
43
+ }
44
+
45
+ /**
46
+ * Embed one or more digital signatures into a PDF document with
47
+ * proper PKCS#7/CMS cryptographic embedding (PAdES compliant).
36
48
  *
37
- * Supports two call patterns:
38
- * 1. Single signature — pass options with a single `signature` field
39
- * 2. Multiple signer — pass options with a `signatures[]` array
49
+ * Creates a PDF /Sig dictionary with /ByteRange and /Contents
50
+ * that Adobe Reader, Foxit, and other PDF readers recognise as
51
+ * a valid digital signature.
40
52
  *
41
- * Each signature gets its own visual stamp matching the CertySign platform:
42
- * Digitally signed by: <email>
43
- * Date: <ISO 8601>
44
- * Certificate: <serial>
45
- * Standard: PAdES Baseline B-B
53
+ * **Recommended:** Pass a `signCallback` function — the embedder will:
54
+ * 1. Prepare the PDF with visual stamps and an empty /Sig placeholder
55
+ * 2. Compute the SHA-256 hash of the ByteRange regions
56
+ * 3. Call your `signCallback(hash)` to get the HSM signature
57
+ * 4. Build a valid PKCS#7 and patch it into /Contents
46
58
  *
47
- * Multiple stamps are stacked from the bottom of the page upward,
48
- * or positioned explicitly via `signaturePosition`.
59
+ * This produces a cryptographically valid PAdES signature that
60
+ * Adobe Reader, Foxit, and other PDF readers can verify.
49
61
  *
50
62
  * @param {Buffer} pdfBuffer - Original unsigned PDF
51
63
  * @param {EmbedPdfOptions} options
@@ -54,13 +66,16 @@ class SignatureEmbedder {
54
66
  async embedInPdf(pdfBuffer, options) {
55
67
  if (!pdfBuffer) throw new Error('embedInPdf: pdfBuffer is required');
56
68
 
57
- let PDFDocument, PDFName, PDFHexString, PDFDict, rgb, StandardFonts;
69
+ let PDFDocument, PDFName, PDFHexString, PDFString, PDFDict, PDFArray, PDFNumber, rgb, StandardFonts;
58
70
  try {
59
71
  const pdfLib = require('pdf-lib');
60
72
  PDFDocument = pdfLib.PDFDocument;
61
73
  PDFName = pdfLib.PDFName;
62
74
  PDFHexString = pdfLib.PDFHexString;
75
+ PDFString = pdfLib.PDFString;
63
76
  PDFDict = pdfLib.PDFDict;
77
+ PDFArray = pdfLib.PDFArray;
78
+ PDFNumber = pdfLib.PDFNumber;
64
79
  rgb = pdfLib.rgb;
65
80
  StandardFonts = pdfLib.StandardFonts;
66
81
  } catch {
@@ -69,134 +84,233 @@ class SignatureEmbedder {
69
84
  );
70
85
  }
71
86
 
87
+ let forge;
88
+ try {
89
+ forge = require('node-forge');
90
+ } catch {
91
+ throw new Error(
92
+ 'node-forge is required for cryptographic PDF signing. Install it: npm install node-forge'
93
+ );
94
+ }
95
+
72
96
  const pdfDoc = await PDFDocument.load(pdfBuffer, { ignoreEncryption: true });
73
97
  const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
74
98
  const pages = pdfDoc.getPages();
75
99
 
76
- // ── Normalise to an array of signature entries ──
77
- const sigEntries = options.signatures
78
- ? options.signatures
79
- : [options];
100
+ // ── Normalise to a single signature entry ──
101
+ // For PAdES we process one signature at a time (each needs its own ByteRange)
102
+ const entry = options.signatures ? options.signatures[0] : options;
103
+ if (!entry) throw new Error('embedInPdf: at least one signature entry is required');
104
+
105
+ const signerEmail = entry.signerEmail || entry.recipientEmail || entry.signerName || 'CertySign';
106
+ const certSerial = entry.certSerialNumber || '';
107
+ const signDate = entry.timestamp ? new Date(entry.timestamp) :
108
+ entry.signedAt ? new Date(entry.signedAt) : new Date();
109
+ const reason = entry.reason || 'Digital signature';
110
+ const location = entry.location || '';
111
+ const certPem = entry.certificate || null;
112
+ const chainPem = entry.chain || null;
113
+ const signCallback = entry.signCallback || options.signCallback || null;
114
+ const precomputedSig = entry.signature || null;
115
+ const tsaUrl = entry.tsaUrl || options.tsaUrl || this.tsaUrl || null;
116
+ const standard = entry.standard || (tsaUrl ? 'PAdES Baseline B-T' : 'PAdES Baseline B-B');
117
+
118
+ if (!signCallback && !precomputedSig) {
119
+ throw new Error('embedInPdf: either signCallback or signature is required');
120
+ }
121
+
122
+ // ── Determine target page ──
123
+ const pos = entry.signaturePosition || {};
124
+ const pageIdx = pos.page
125
+ ? Math.min(pos.page - 1, pages.length - 1)
126
+ : pages.length - 1;
127
+ const sigPage = pages[pageIdx];
128
+
129
+ // 4 text lines = height
130
+ const stampHeight = (4 * STAMP_LINE_HEIGHT) + (2 * STAMP_PADDING_Y);
131
+ const stampWidth = pos.width || STAMP_WIDTH;
132
+ const x = pos.x != null ? pos.x : 20;
133
+ const y = pos.y != null ? pos.y : 20;
134
+
135
+ // ── Draw stamp box ──
136
+ sigPage.drawRectangle({
137
+ x, y,
138
+ width: stampWidth,
139
+ height: stampHeight,
140
+ borderColor: rgb(0.4, 0.4, 0.4),
141
+ borderWidth: 0.75,
142
+ color: rgb(0.97, 0.97, 0.97),
143
+ opacity: 0.95
144
+ });
80
145
 
81
- if (sigEntries.length === 0) {
82
- throw new Error('embedInPdf: at least one signature is required');
146
+ // ── Draw text lines matching CertySign platform format ──
147
+ const textLines = [
148
+ `Digitally signed by: ${signerEmail}`,
149
+ `Date: ${signDate.toISOString()}`,
150
+ `Certificate: ${certSerial}`,
151
+ `Standard: ${standard}`
152
+ ];
153
+
154
+ let textY = y + stampHeight - STAMP_PADDING_Y - STAMP_FONT_SIZE;
155
+ for (const line of textLines) {
156
+ sigPage.drawText(line, {
157
+ x: x + STAMP_PADDING_X,
158
+ y: textY,
159
+ size: STAMP_FONT_SIZE,
160
+ font,
161
+ color: rgb(0.4, 0.4, 0.4),
162
+ maxWidth: stampWidth - (2 * STAMP_PADDING_X)
163
+ });
164
+ textY -= STAMP_LINE_HEIGHT;
83
165
  }
84
166
 
85
- // Track how many stamps we've drawn per page for auto-stacking
86
- const pageStampCounts = {};
167
+ // ── Create the /Sig dictionary with empty placeholder ──
168
+ const sigDict = pdfDoc.context.obj({
169
+ Type: 'Sig',
170
+ Filter: 'Adobe.PPKLite',
171
+ SubFilter: 'adbe.pkcs7.detached',
172
+ // Large placeholder numbers ensure enough bytes for patching
173
+ ByteRange: [0, 9999999999, 9999999999, 9999999999],
174
+ Contents: PDFHexString.of('0'.repeat(SIG_PLACEHOLDER_LENGTH)),
175
+ Reason: PDFString.of(reason),
176
+ M: PDFString.of(_pdfDate(signDate)),
177
+ Name: PDFString.of(signerEmail),
178
+ Location: location ? PDFString.of(location) : undefined,
179
+ ContactInfo: PDFString.of(signerEmail)
180
+ });
87
181
 
88
- for (let idx = 0; idx < sigEntries.length; idx++) {
89
- const entry = sigEntries[idx];
90
- const sig = entry.signature;
91
- if (!sig) throw new Error(`embedInPdf: signature is required (entry ${idx})`);
92
-
93
- const signerEmail = entry.signerEmail || entry.recipientEmail || entry.signerName || 'CertySign';
94
- const certSerial = entry.certSerialNumber || '';
95
- const signDate = entry.timestamp ? new Date(entry.timestamp) :
96
- entry.signedAt ? new Date(entry.signedAt) : new Date();
97
- const reason = entry.reason || 'Digital signature';
98
- const location = entry.location || '';
99
- const standard = entry.standard || 'PAdES Baseline B-B';
100
-
101
- // ── Determine target page ──
102
- const pos = entry.signaturePosition || {};
103
- const pageIdx = pos.page
104
- ? Math.min(pos.page - 1, pages.length - 1)
105
- : pages.length - 1;
106
- const sigPage = pages[pageIdx];
107
-
108
- // ── Auto-stacking: each stamp stacks above the previous one ──
109
- const stampKey = pageIdx;
110
- if (!(stampKey in pageStampCounts)) pageStampCounts[stampKey] = 0;
111
- const stampIndex = pageStampCounts[stampKey];
112
- pageStampCounts[stampKey]++;
113
-
114
- // 4 text lines = height
115
- const stampHeight = (4 * STAMP_LINE_HEIGHT) + (2 * STAMP_PADDING_Y);
116
- const stampWidth = pos.width || STAMP_WIDTH;
117
-
118
- // Position: explicit position or auto-stack from bottom-left
119
- const x = pos.x != null ? pos.x : 20;
120
- const baseY = pos.y != null ? pos.y : 20;
121
- const y = baseY + stampIndex * (stampHeight + STAMP_MARGIN);
122
-
123
- // ── Draw stamp box ──
124
- sigPage.drawRectangle({
125
- x, y,
126
- width: stampWidth,
127
- height: stampHeight,
128
- borderColor: rgb(0.4, 0.4, 0.4),
129
- borderWidth: 0.75,
130
- color: rgb(0.97, 0.97, 0.97),
131
- opacity: 0.95
182
+ const sigDictRef = pdfDoc.context.register(sigDict);
183
+
184
+ // ── Create widget annotation for the visual stamp ──
185
+ const widgetDict = pdfDoc.context.obj({
186
+ Type: 'Annot',
187
+ Subtype: 'Widget',
188
+ FT: 'Sig',
189
+ Rect: [x, y, x + stampWidth, y + stampHeight],
190
+ V: sigDictRef,
191
+ T: PDFString.of('CertySign-Sig-0'),
192
+ F: 4, // Print flag
193
+ P: pages[pageIdx].ref,
194
+ });
195
+
196
+ const widgetRef = pdfDoc.context.register(widgetDict);
197
+
198
+ // Add widget to page Annots array
199
+ const pageDict = pages[pageIdx].node;
200
+ let annots = pageDict.lookup(PDFName.of('Annots'));
201
+ if (!annots || !(annots instanceof PDFArray)) {
202
+ annots = pdfDoc.context.obj([]);
203
+ pageDict.set(PDFName.of('Annots'), annots);
204
+ }
205
+ annots.push(widgetRef);
206
+
207
+ // Add to AcroForm
208
+ let acroForm = pdfDoc.catalog.lookup(PDFName.of('AcroForm'));
209
+ if (!acroForm || !(acroForm instanceof PDFDict)) {
210
+ acroForm = pdfDoc.context.obj({
211
+ Fields: [],
212
+ SigFlags: 3 // SignaturesExist | AppendOnly
132
213
  });
214
+ pdfDoc.catalog.set(PDFName.of('AcroForm'), acroForm);
215
+ } else {
216
+ acroForm.set(PDFName.of('SigFlags'), PDFNumber.of(3));
217
+ }
133
218
 
134
- // ── Draw text lines matching CertySign platform format ──
135
- const textLines = [
136
- `Digitally signed by: ${signerEmail}`,
137
- `Date: ${signDate.toISOString()}`,
138
- `Certificate: ${certSerial}`,
139
- `Standard: ${standard}`
140
- ];
141
-
142
- let textY = y + stampHeight - STAMP_PADDING_Y - STAMP_FONT_SIZE;
143
- for (const line of textLines) {
144
- sigPage.drawText(line, {
145
- x: x + STAMP_PADDING_X,
146
- y: textY,
147
- size: STAMP_FONT_SIZE,
148
- font,
149
- color: rgb(0.4, 0.4, 0.4),
150
- maxWidth: stampWidth - (2 * STAMP_PADDING_X)
151
- });
152
- textY -= STAMP_LINE_HEIGHT;
153
- }
219
+ let fields = acroForm.lookup(PDFName.of('Fields'));
220
+ if (!fields || !(fields instanceof PDFArray)) {
221
+ fields = pdfDoc.context.obj([]);
222
+ acroForm.set(PDFName.of('Fields'), fields);
154
223
  }
224
+ fields.push(widgetRef);
155
225
 
156
- // ── Embed signature metadata as document properties ──
157
- // Store the last (or primary) signature's CMS data for programmatic verification
158
- const primary = sigEntries[0];
159
- const sigMetadata = {
160
- 'CertySign-SignatureCount': String(sigEntries.length),
161
- 'CertySign-Algorithm': primary.algorithm || 'SHA256withRSA',
162
- 'CertySign-CertSerial': primary.certSerialNumber || '',
163
- 'CertySign-Timestamp': (primary.timestamp ? new Date(primary.timestamp) : new Date()).toISOString(),
164
- 'CertySign-Signer': primary.signerEmail || primary.recipientEmail || primary.signerName || 'CertySign',
165
- 'CertySign-DocumentHash': primary.documentHash || '',
166
- 'CertySign-HashAlgorithm': primary.hashAlgorithm || 'sha256',
167
- 'CertySign-Standard': primary.standard || 'PAdES Baseline B-B'
168
- };
226
+ pdfDoc.setProducer('CertySign Trust Services');
227
+ pdfDoc.setModificationDate(new Date());
228
+
229
+ // ── Phase 1: Save PDF with empty signature placeholder ──
230
+ let pdfBytes = Buffer.from(await pdfDoc.save({ useObjectStreams: false }));
169
231
 
170
- // Store each signature in metadata
171
- for (let i = 0; i < sigEntries.length; i++) {
172
- const entry = sigEntries[i];
173
- sigMetadata[`CertySign-Signature-${i}`] = entry.signature;
174
- if (entry.certSerialNumber) {
175
- sigMetadata[`CertySign-CertSerial-${i}`] = entry.certSerialNumber;
232
+ // ── Phase 2: Patch ByteRange and compute hash of signed regions ──
233
+ const pdfStr = pdfBytes.toString('latin1');
234
+ const placeholderHex = '0'.repeat(SIG_PLACEHOLDER_LENGTH);
235
+ const placeholderIdx = pdfStr.indexOf(placeholderHex);
236
+ if (placeholderIdx === -1) {
237
+ throw new Error('embedInPdf: could not find signature placeholder in saved PDF');
238
+ }
239
+
240
+ const contentsStart = placeholderIdx - 1; // byte offset of '<'
241
+ const contentsEnd = placeholderIdx + SIG_PLACEHOLDER_LENGTH + 1; // byte after '>'
242
+ const totalLen = pdfBytes.length;
243
+
244
+ const br0 = 0;
245
+ const br1 = contentsStart;
246
+ const br2 = contentsEnd;
247
+ const br3 = totalLen - contentsEnd;
248
+
249
+ // Patch ByteRange values in-place
250
+ const searchStart = Math.max(0, placeholderIdx - 500);
251
+ const searchRegion = pdfStr.substring(searchStart, placeholderIdx);
252
+ const brRelIdx = searchRegion.lastIndexOf('/ByteRange');
253
+ if (brRelIdx !== -1) {
254
+ const absIdx = searchStart + brRelIdx;
255
+ const closeBracket = pdfStr.indexOf(']', absIdx);
256
+ if (closeBracket !== -1) {
257
+ const oldBrLen = closeBracket + 1 - absIdx;
258
+ const newBrValue = `/ByteRange [${br0} ${br1} ${br2} ${br3}]`;
259
+ const newBr = newBrValue.padEnd(oldBrLen, ' ');
260
+ pdfBytes.write(newBr, absIdx, oldBrLen, 'latin1');
176
261
  }
177
- const signer = entry.signerEmail || entry.recipientEmail || entry.signerName || '';
178
- if (signer) sigMetadata[`CertySign-Signer-${i}`] = signer;
179
262
  }
180
263
 
181
- if (primary.certificate) {
182
- sigMetadata['CertySign-Certificate'] = primary.certificate.replace(/\n/g, '\\n');
264
+ // ── Phase 3: Compute hash of the ByteRange regions (everything except /Contents hex) ──
265
+ const byteRangeHash = crypto.createHash('sha256');
266
+ byteRangeHash.update(pdfBytes.subarray(br0, br0 + br1)); // before <Contents>
267
+ byteRangeHash.update(pdfBytes.subarray(br2, br2 + br3)); // after <Contents>
268
+ const hashHex = byteRangeHash.digest('hex');
269
+
270
+ // ── Phase 4: Get the signature ──
271
+ let rawSigBase64;
272
+ let sigCertPem = certPem;
273
+ let sigChainPem = chainPem;
274
+
275
+ if (signCallback) {
276
+ // Call the HSM to sign the ByteRange hash — produces a valid PAdES signature
277
+ const signResult = await signCallback(hashHex);
278
+ rawSigBase64 = signResult.data?.signature || signResult.signature;
279
+ sigCertPem = sigCertPem || signResult.data?.certificate || signResult.certificate;
280
+ sigChainPem = sigChainPem || signResult.data?.chain || signResult.chain;
281
+ } else {
282
+ // Legacy: use pre-computed signature (won't validate against ByteRange)
283
+ rawSigBase64 = precomputedSig;
183
284
  }
184
- if (primary.chain) {
185
- sigMetadata['CertySign-Chain'] = primary.chain.replace(/\n/g, '\\n');
285
+
286
+ // ── Phase 4.5: Fetch TSA timestamp token (PAdES B-B → PAdES B-T) ──
287
+ let timestampTokenAsn1 = null;
288
+ if (tsaUrl) {
289
+ // Hash the raw signature value — the timestamp proves the signature existed at this time
290
+ const rawSigBytes = Buffer.from(rawSigBase64, 'base64');
291
+ const sigHash = crypto.createHash('sha256').update(rawSigBytes).digest('hex');
292
+ timestampTokenAsn1 = await _fetchTimestamp(forge, tsaUrl, sigHash);
186
293
  }
187
294
 
188
- const infoDict = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Info);
189
- if (infoDict instanceof PDFDict) {
190
- for (const [key, value] of Object.entries(sigMetadata)) {
191
- infoDict.set(PDFName.of(key), PDFHexString.fromText(value));
192
- }
295
+ // ── Phase 5: Build PKCS#7 and patch into /Contents ──
296
+ const p7 = _buildPkcs7(forge, rawSigBase64, sigCertPem, sigChainPem, signDate, {
297
+ signerEmail, reason, location, certSerial
298
+ }, timestampTokenAsn1);
299
+ const p7Der = forge.asn1.toDer(p7).getBytes();
300
+ const p7Hex = Buffer.from(p7Der, 'binary').toString('hex');
301
+
302
+ if (p7Hex.length > SIG_PLACEHOLDER_LENGTH) {
303
+ throw new Error(
304
+ `PKCS#7 signature too large (${p7Hex.length} hex chars > ${SIG_PLACEHOLDER_LENGTH} placeholder). ` +
305
+ 'Increase SIG_PLACEHOLDER_LENGTH.'
306
+ );
193
307
  }
194
308
 
195
- pdfDoc.setProducer('CertySign Trust Services');
196
- pdfDoc.setModificationDate(new Date());
309
+ // Write PKCS#7 hex into the /Contents placeholder
310
+ const paddedHex = p7Hex.padEnd(SIG_PLACEHOLDER_LENGTH, '0');
311
+ pdfBytes.write(paddedHex, placeholderIdx, SIG_PLACEHOLDER_LENGTH, 'latin1');
197
312
 
198
- const signedBytes = await pdfDoc.save();
199
- return Buffer.from(signedBytes);
313
+ return pdfBytes;
200
314
  }
201
315
 
202
316
  /**
@@ -356,6 +470,293 @@ function _xmlEscape(str) {
356
470
  .replace(/'/g, '&apos;');
357
471
  }
358
472
 
473
+ /**
474
+ * Build a PKCS#7 SignedData ASN.1 structure wrapping the raw RSA signature
475
+ * and the signer certificate (+ optional chain).
476
+ *
477
+ * The HSM already produced the raw RSA signature over the document hash.
478
+ * We wrap it into a CMS SignedData container so PDF readers recognise it.
479
+ *
480
+ * When timestampTokenAsn1 is provided, it is added as an unsigned attribute
481
+ * (id-smime-aa-timeStampToken, OID 1.2.840.113549.1.9.16.2.14) in SignerInfo,
482
+ * upgrading the signature from PAdES B-B to PAdES B-T.
483
+ */
484
+ function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta, timestampTokenAsn1) {
485
+ const rawSigBytes = forge.util.decode64(rawSigBase64);
486
+
487
+ // Parse the signing certificate
488
+ const certs = [];
489
+ if (certPem) {
490
+ certs.push(forge.pki.certificateFromPem(certPem));
491
+ }
492
+ if (chainPem) {
493
+ // Chain may contain multiple PEM blocks
494
+ const chainBlocks = chainPem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
495
+ for (const block of chainBlocks) {
496
+ try { certs.push(forge.pki.certificateFromPem(block)); } catch { /* skip invalid */ }
497
+ }
498
+ }
499
+
500
+ const signerCert = certs[0] || null;
501
+
502
+ // ── Build ASN.1 SignedData manually ──
503
+ // OID: 1.2.840.113549.1.7.2 (signedData)
504
+ const contentType = forge.asn1.oidToDer('1.2.840.113549.1.7.2').getBytes();
505
+ // OID: 1.2.840.113549.1.7.1 (data)
506
+ const dataType = forge.asn1.oidToDer('1.2.840.113549.1.7.1').getBytes();
507
+ // OID: 2.16.840.1.101.3.4.2.1 (sha-256)
508
+ const sha256Oid = forge.asn1.oidToDer('2.16.840.1.101.3.4.2.1').getBytes();
509
+
510
+ // Create certificate SET
511
+ const certSet = [];
512
+ for (const cert of certs) {
513
+ certSet.push(forge.asn1.fromDer(forge.asn1.toDer(forge.pki.certificateToAsn1(cert))));
514
+ }
515
+
516
+ // Build SignerInfo
517
+ const signerInfoSets = [];
518
+ if (signerCert) {
519
+ const issuerSeq = forge.asn1.fromDer(
520
+ forge.asn1.toDer(forge.pki.distinguishedNameToAsn1(signerCert.issuer))
521
+ );
522
+ const serialInt = forge.asn1.create(
523
+ forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false,
524
+ forge.util.hexToBytes(signerCert.serialNumber)
525
+ );
526
+
527
+ // Authenticated attributes
528
+ const signingTime = forge.asn1.create(
529
+ forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
530
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
531
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false,
532
+ forge.asn1.oidToDer('1.2.840.113549.1.9.5').getBytes()), // signingTime OID
533
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
534
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.UTCTIME, false,
535
+ _asn1UtcTime(signDate))
536
+ ])
537
+ ])
538
+ ]
539
+ );
540
+
541
+ const signerInfo = forge.asn1.create(
542
+ forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
543
+ // version
544
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false,
545
+ forge.util.hexToBytes('01')),
546
+ // issuerAndSerialNumber
547
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
548
+ issuerSeq,
549
+ serialInt
550
+ ]),
551
+ // digestAlgorithm
552
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
553
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, sha256Oid),
554
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.NULL, false, '')
555
+ ]),
556
+ // signatureAlgorithm (rsaEncryption)
557
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
558
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false,
559
+ forge.asn1.oidToDer('1.2.840.113549.1.1.1').getBytes()),
560
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.NULL, false, '')
561
+ ]),
562
+ // signature
563
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false,
564
+ rawSigBytes),
565
+ // unauthenticatedAttributes [1] IMPLICIT (PAdES-T timestamp token)
566
+ ...(timestampTokenAsn1 ? [
567
+ forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 1, true, [
568
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
569
+ // id-smime-aa-timeStampToken (1.2.840.113549.1.9.16.2.14)
570
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false,
571
+ forge.asn1.oidToDer('1.2.840.113549.1.9.16.2.14').getBytes()),
572
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
573
+ timestampTokenAsn1
574
+ ])
575
+ ])
576
+ ])
577
+ ] : [])
578
+ ]
579
+ );
580
+ signerInfoSets.push(signerInfo);
581
+ }
582
+
583
+ // SignedData SEQUENCE
584
+ const signedData = forge.asn1.create(
585
+ forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
586
+ // version
587
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false,
588
+ forge.util.hexToBytes('01')),
589
+ // digestAlgorithms SET
590
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
591
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
592
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, sha256Oid),
593
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.NULL, false, '')
594
+ ])
595
+ ]),
596
+ // contentInfo (empty data — detached signature)
597
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
598
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, dataType)
599
+ ]),
600
+ // certificates [0] IMPLICIT
601
+ ...(certSet.length > 0 ? [
602
+ forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, certSet)
603
+ ] : []),
604
+ // signerInfos SET
605
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, signerInfoSets)
606
+ ]
607
+ );
608
+
609
+ // Wrap in ContentInfo
610
+ const contentInfo = forge.asn1.create(
611
+ forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
612
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, contentType),
613
+ forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [signedData])
614
+ ]
615
+ );
616
+
617
+ return contentInfo;
618
+ }
619
+
620
+ /**
621
+ * Convert a Date to ASN.1 UTCTime format (YYMMDDHHmmssZ)
622
+ */
623
+ function _asn1UtcTime(date) {
624
+ const pad = n => String(n).padStart(2, '0');
625
+ const y = date.getUTCFullYear() % 100;
626
+ return `${pad(y)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
627
+ `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
628
+ }
629
+
630
+ /**
631
+ * Convert a Date to PDF date format (D:YYYYMMDDHHmmss+00'00')
632
+ */
633
+ function _pdfDate(date) {
634
+ const pad = n => String(n).padStart(2, '0');
635
+ return `D:${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
636
+ `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}+00'00'`;
637
+ }
638
+
639
+ /**
640
+ * Fetch an RFC 3161 timestamp token from the TSA Service.
641
+ *
642
+ * Calls the TSA JSON endpoint with the SHA-256 hash of the signature value.
643
+ * Parses the TimeStampResp and extracts the TimeStampToken (ContentInfo).
644
+ *
645
+ * @param {Object} forge - node-forge instance
646
+ * @param {string} tsaUrl - Base URL of the TSA Service (e.g. http://localhost:5015)
647
+ * @param {string} sigHashHex - SHA-256 hash of the signature value (hex)
648
+ * @returns {Object} ASN.1 ContentInfo (TimeStampToken) for embedding as unsigned attribute
649
+ */
650
+ async function _fetchTimestamp(forge, tsaUrl, sigHashHex) {
651
+ const http = require('http');
652
+ const https = require('https');
653
+ const url = new URL('/v1/tsa/json', tsaUrl);
654
+
655
+ const body = JSON.stringify({
656
+ hash: sigHashHex,
657
+ hashAlgorithm: 'sha-256',
658
+ mode: 'classical',
659
+ certReq: true,
660
+ });
661
+
662
+ const transport = url.protocol === 'https:' ? https : http;
663
+
664
+ const tsaResp = await new Promise((resolve, reject) => {
665
+ const req = transport.request(url, {
666
+ method: 'POST',
667
+ headers: {
668
+ 'Content-Type': 'application/json',
669
+ 'Content-Length': Buffer.byteLength(body),
670
+ },
671
+ timeout: 15000,
672
+ }, (res) => {
673
+ const chunks = [];
674
+ res.on('data', c => chunks.push(c));
675
+ res.on('end', () => {
676
+ try {
677
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
678
+ } catch (e) {
679
+ reject(new Error(`TSA response parse error: ${e.message}`));
680
+ }
681
+ });
682
+ });
683
+
684
+ req.on('error', e => reject(new Error(`TSA request failed: ${e.message}`)));
685
+ req.on('timeout', () => { req.destroy(); reject(new Error('TSA request timed out')); });
686
+ req.write(body);
687
+ req.end();
688
+ });
689
+
690
+ if (!tsaResp.success || !tsaResp.token) {
691
+ throw new Error(`TSA timestamp request failed: ${tsaResp.error || 'no token returned'}`);
692
+ }
693
+
694
+ // Parse TimeStampResp → extract TimeStampToken (ContentInfo)
695
+ const respDer = Buffer.from(tsaResp.token, 'base64');
696
+ const respAsn1 = forge.asn1.fromDer(forge.util.createBuffer(respDer));
697
+
698
+ // TimeStampResp ::= SEQUENCE { PKIStatusInfo, TimeStampToken OPTIONAL }
699
+ if (!respAsn1.value || respAsn1.value.length < 2) {
700
+ throw new Error('Invalid TimeStampResp: missing TimeStampToken');
701
+ }
702
+
703
+ // respAsn1.value[1] is the ContentInfo (TimeStampToken)
704
+ return respAsn1.value[1];
705
+ }
706
+
707
+ /**
708
+ * Patch the placeholder /Contents hex string in the saved PDF with actual PKCS#7 DER bytes.
709
+ * Searches for the placeholder pattern and replaces it with the real signature.
710
+ */
711
+ function _patchSignatureContents(pdfBytes, p7Hex) {
712
+ // The placeholder is a hex string of null bytes (00) of length SIG_PLACEHOLDER_LENGTH
713
+ const placeholderHex = '0'.repeat(SIG_PLACEHOLDER_LENGTH);
714
+ const pdfStr = pdfBytes.toString('latin1');
715
+
716
+ // Find the placeholder in the PDF — it appears inside angle brackets: <0000...0000>
717
+ const placeholderIdx = pdfStr.indexOf(placeholderHex);
718
+ if (placeholderIdx === -1) {
719
+ return pdfBytes;
720
+ }
721
+
722
+ // Pad the actual signature hex to fill the full placeholder length
723
+ const paddedHex = p7Hex.padEnd(SIG_PLACEHOLDER_LENGTH, '0');
724
+
725
+ // Replace in a mutable buffer
726
+ const result = Buffer.from(pdfBytes);
727
+ result.write(paddedHex, placeholderIdx, SIG_PLACEHOLDER_LENGTH, 'latin1');
728
+
729
+ // Calculate actual ByteRange values
730
+ // '<' is at placeholderIdx - 1, '>' is at placeholderIdx + SIG_PLACEHOLDER_LENGTH
731
+ const contentsStart = placeholderIdx - 1; // byte offset of '<'
732
+ const contentsEnd = placeholderIdx + SIG_PLACEHOLDER_LENGTH + 1; // byte after '>'
733
+ const totalLen = result.length;
734
+
735
+ // ByteRange: [before_sig_start, before_sig_len, after_sig_start, after_sig_len]
736
+ const br0 = 0;
737
+ const br1 = contentsStart;
738
+ const br2 = contentsEnd;
739
+ const br3 = totalLen - contentsEnd;
740
+
741
+ // Find /ByteRange [...] near the signature and patch it
742
+ const searchStart = Math.max(0, placeholderIdx - 500);
743
+ const searchRegion = pdfStr.substring(searchStart, placeholderIdx);
744
+ const brRelIdx = searchRegion.lastIndexOf('/ByteRange');
745
+ if (brRelIdx !== -1) {
746
+ const absIdx = searchStart + brRelIdx;
747
+ const closeBracket = pdfStr.indexOf(']', absIdx);
748
+ if (closeBracket !== -1) {
749
+ const oldBrLen = closeBracket + 1 - absIdx;
750
+ const newBrValue = `/ByteRange [${br0} ${br1} ${br2} ${br3}]`;
751
+ // Pad with spaces to exactly match original length — prevents shifting bytes
752
+ const newBr = newBrValue.padEnd(oldBrLen, ' ');
753
+ result.write(newBr, absIdx, oldBrLen, 'latin1');
754
+ }
755
+ }
756
+
757
+ return result;
758
+ }
759
+
359
760
  /**
360
761
  * @typedef {Object} SignatureEntry
361
762
  * @property {string} signature - Base64 CMS/PKCS#7 from CertySign