@certysign/sdk 2.1.0 → 2.3.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 CHANGED
@@ -26,6 +26,7 @@ Official Node.js SDK for **CertySign Trust Services** — hash-based digital sig
26
26
  - [Complete Examples](#complete-examples)
27
27
  - [Error Handling](#error-handling)
28
28
  - [Rate Limiting](#rate-limiting)
29
+ - [Timestamp Authority (TSA)](#timestamp-authority-tsa)
29
30
  - [Migration from v1](#migration-from-v1)
30
31
  - [PKI Trust Chain](#pki-trust-chain)
31
32
  - [Security Best Practices](#security-best-practices)
@@ -35,65 +36,26 @@ Official Node.js SDK for **CertySign Trust Services** — hash-based digital sig
35
36
 
36
37
  ## Installation
37
38
 
38
- ### Local / internal use (current)
39
-
40
- The SDK is not yet published to the public npm registry. Use one of the methods below.
41
-
42
- **Option A — file path dependency** (recommended for services in this monorepo):
43
-
44
- ```bash
45
- # From any service directory, e.g. a DHA integration project:
46
- npm install ../../sdk
47
- ```
48
-
49
- This adds the following to the consuming project's `package.json`:
50
-
51
- ```json
52
- "dependencies": {
53
- "@certysign/sdk": "file:../../sdk"
54
- }
55
- ```
56
-
57
- **Option B — `npm link`** (for local development outside the monorepo):
58
-
59
- ```bash
60
- # In the sdk/ directory — register it globally once:
61
- cd sdk
62
- npm link
63
-
64
- # In any other project:
65
- npm link @certysign/sdk
66
- ```
67
-
68
- **Option C — install directly from the folder path**:
69
-
70
- ```bash
71
- npm install /absolute/path/to/Certy_Sign/sdk
72
- ```
73
-
74
- ### Publishing to npm (when ready)
75
-
76
- ```bash
77
- # Log in to npm:
78
- npm login
79
-
80
- # From the sdk/ directory:
81
- npm publish --access public
82
- ```
83
-
84
- After publishing, standard installation will work:
85
-
86
39
  ```bash
87
40
  npm install @certysign/sdk
88
41
  ```
89
42
 
90
43
  Node.js >= 18 required.
91
44
 
45
+ **Peer dependencies** (installed automatically):
46
+ - `pdf-lib` — PDF manipulation for visual stamps and /Sig dictionaries
47
+ - `node-forge` — PKCS#7/CMS ASN.1 construction for cryptographic embedding
48
+
92
49
  ---
93
50
 
94
51
  ## Quick Start
95
52
 
96
- ### Sign a single document (hash-based)
53
+ ### PAdES Signing — Cryptographically Valid PDF Signatures (Recommended)
54
+
55
+ This is the **recommended** approach for v2.2.0+. It produces **PAdES Baseline B-T**
56
+ PDF signatures with embedded RFC 3161 timestamps, recognised as valid by
57
+ Adobe Reader, Foxit Reader, and other PDF validators. The TSA URL is resolved
58
+ automatically from your environment — no configuration needed.
97
59
 
98
60
  ```js
99
61
  const { CertySignClient } = require('@certysign/sdk');
@@ -105,28 +67,68 @@ const client = new CertySignClient({
105
67
  environment: 'production' // 'staging' | 'development'
106
68
  });
107
69
 
108
- // 1. Hash locally — document never leaves your system
109
70
  const pdfBuffer = fs.readFileSync('./claim.pdf');
110
71
 
111
- // 2. Hash & sign in one call (SDK hashes locally, sends only hash)
72
+ // Get the active certificate for the visual stamp
73
+ const cert = await client.certificates.getActive();
74
+
75
+ // One call does everything:
76
+ // 1. Prepares PDF with visual stamp + /Sig placeholder
77
+ // 2. Computes SHA-256 hash of the ByteRange regions
78
+ // 3. Calls your signCallback to sign the hash via HSM
79
+ // 4. Builds PKCS#7 SignedData and patches into /Contents
80
+ const signedPdf = await client.embedder.embedInPdf(pdfBuffer, {
81
+ certSerialNumber: cert.data?.serialNumber,
82
+ signerEmail: 'dr.okonkwo@dha.go.ke',
83
+ signerName: 'Dr. Amina Okonkwo',
84
+ reason: 'Health claim approval — DHA Kenya',
85
+ location: 'Nairobi, Kenya',
86
+ signCallback: async (byteRangeHash) => {
87
+ // The embedder computed the ByteRange hash — now sign it via HSM
88
+ return client.sign.signHash({
89
+ documentHash: byteRangeHash,
90
+ hashAlgorithm: 'sha256',
91
+ fileName: 'claim.pdf',
92
+ reason: 'Health claim approval'
93
+ });
94
+ }
95
+ });
96
+
97
+ fs.writeFileSync('./claim-signed.pdf', signedPdf);
98
+ // Open in Adobe Reader → Signature Panel shows:
99
+ // ✓ Valid digital signature
100
+ // ✓ Timestamp: RFC 3161 (proves when it was signed)
101
+ // ✓ Standard: PAdES Baseline B-T
102
+ ```
103
+
104
+ ### Simple Signing — Hash → Sign → Embed (Legacy approach)
105
+
106
+ This approach signs the document hash first, then embeds the pre-computed signature.
107
+ The PDF will have visual stamps and PKCS#7 structure, but the signature covers the
108
+ original document hash rather than the ByteRange, so PDF readers may show a warning.
109
+
110
+ ```js
111
+ const pdfBuffer = fs.readFileSync('./claim.pdf');
112
+
113
+ // 1. Hash locally & sign remotely
112
114
  const result = await client.sign.hashAndSign({
113
115
  document: pdfBuffer,
114
116
  fileName: 'claim.pdf',
115
117
  signerName: 'Dr. Amina Okonkwo',
116
- reason: 'Health claim approval — DHA Kenya',
117
- location: 'Nairobi, Kenya'
118
+ reason: 'Health claim approval'
118
119
  });
119
120
 
120
- // 3. Embed the signature into the PDF locally
121
+ // 2. Embed signature into PDF
121
122
  const signedPdf = await client.embedder.embedInPdf(pdfBuffer, {
122
- signature: result.data.signature,
123
- certificate: result.data.certificate.pem,
124
- certSerialNumber: result.data.certificate.serialNumber,
125
- signerName: 'Dr. Amina Okonkwo',
126
- reason: 'Health claim approval — DHA Kenya',
127
- documentHash: result.data.documentHash,
128
- hashAlgorithm: result.data.hashAlgorithm,
129
- algorithm: result.data.algorithm
123
+ signature: result.data.signature,
124
+ certificate: result.data.certificate, // PEM string
125
+ certSerialNumber: result.data.certSerialNumber,
126
+ chain: result.data.chain, // PEM chain string
127
+ signerName: 'Dr. Amina Okonkwo',
128
+ reason: 'Health claim approval',
129
+ documentHash: result.data.documentHash,
130
+ hashAlgorithm: result.data.hashAlgorithm,
131
+ algorithm: result.data.algorithm
130
132
  });
131
133
 
132
134
  fs.writeFileSync('./claim-signed.pdf', signedPdf);
@@ -172,46 +174,53 @@ const signed = await client.sessions.recipientSign(
172
174
 
173
175
  ## Architecture
174
176
 
177
+ ### PAdES Flow (Recommended — `signCallback`)
178
+
175
179
  ```
176
- ┌─────────────────────────────────────────────────────────────────┐
177
- │ SUBSCRIBER'S INFRASTRUCTURE │
178
- │ │
179
- │ ┌──────────┐ hash() ┌──────────────────┐ │
180
- │ │ Document │─────────────▶│ DocumentHasher │ │
181
- │ │ (PDF/XML/ │ │ (SHA-256/384/ │ │
182
- │ │ JSON) │ │ 512) │ │
183
- │ └──────────┘ └────────┬──────────┘ │
184
- │ │ │ hex hash only │
185
- │ │ ▼ │
186
- │ │ ┌───────────────────────────────────┐ │
187
- │ │ │ CertySign API │ │
188
- │ │ │ POST /sdk/v1/sign/hash │ │
189
- │ │ │ ┌─────────────────────────────┐ │ │
190
- │ │ │ │ HSM (Google Cloud KMS) │ │ │
191
- │ │ │ │ Signs hash with tenant key │ │ │
192
- │ │ │ └─────────────────────────────┘ │ │
193
- │ │ └───────────────┬───────────────────┘ │
194
- │ │ │ CMS signature + cert + chain │
195
- │ │ ▼ │
196
- │ │ ┌──────────────────────┐ │
197
- │ └─────────────▶│ SignatureEmbedder │ │
198
- │ │ embedInPdf() │ │
199
- │ │ embedInXml() │ │
200
- │ │ embedInJson() │ │
201
- │ └──────────┬───────────┘ │
202
- │ │ │
203
- │ ▼ │
204
- │ ┌──────────────────────┐ │
205
- │ │ Signed Document │ │
206
- │ │ (stays on YOUR │ │
207
- │ │ system) │ │
208
- │ └──────────────────────┘ │
209
- │ │
210
- └─────────────────────────────────────────────────────────────────┘
180
+ ┌──────────────────────────────────────────────────────────────────┐
181
+ │ SUBSCRIBER'S INFRASTRUCTURE │
182
+ │ │
183
+ │ ┌───────────┐ │
184
+ │ │ PDF │ │
185
+ │ │ Document │─────────────┐ │
186
+ │ └───────────┘ ▼ │
187
+ │ ┌──────────────────────────────┐ │
188
+ │ │ embedInPdf(pdf, { │ │
189
+ │ │ signCallback: async (h) => │ 1. Prepare PDF │
190
+ │ │ client.sign.signHash() │ + visual stamp │
191
+ │ │ }) │ + /Sig dict │
192
+ │ └──────────┬───────────────────┘ │
193
+ │ │ 2. SHA-256(ByteRange) │
194
+ │ ▼ │
195
+ │ ┌───────────────────────────────────┐ │
196
+ │ │ CertySign API │ │
197
+ │ │ POST /sdk/v1/sign/hash │ │
198
+ │ │ ┌─────────────────────────────┐ │ │
199
+ │ │ │ HSM (Google Cloud KMS) │ │ │
200
+ │ │ │ Signs ByteRange hash │ │ │
201
+ │ │ └─────────────────────────────┘ │ │
202
+ │ └───────────────┬───────────────────┘ │
203
+ │ │ RSA signature + cert PEM + chain │
204
+ │ ▼ │
205
+ │ ┌──────────────────────────────┐ │
206
+ │ │ 3. Build PKCS#7 SignedData │ │
207
+ │ │ 4. Patch into /Contents │ │
208
+ │ │ 5. Patch /ByteRange │ │
209
+ │ └──────────────┬───────────────┘ │
210
+ │ ▼ │
211
+ │ ┌──────────────────────────────┐ │
212
+ │ │ PAdES-Signed PDF │ │
213
+ │ │ ✓ Adobe Reader validates │ │
214
+ │ │ ✓ /Sig + /ByteRange + PKCS#7 │ │
215
+ │ │ ✓ RFC 3161 timestamp (TSA) │ │
216
+ │ │ ✓ Stays on YOUR system │ │
217
+ │ └──────────────────────────────┘ │
218
+ │ │
219
+ └──────────────────────────────────────────────────────────────────┘
211
220
  ```
212
221
 
213
- **What crosses the network:** Only the cryptographic hash (64-128 hex characters).
214
- **What stays local:** The original document, the signed document, and the embedding process.
222
+ **What crosses the network:** Only the SHA-256 hash of the PDF ByteRange (64 hex characters).
223
+ **What stays local:** The original document, the signing preparation, the PKCS#7 construction, and the final signed PDF.
215
224
 
216
225
  ---
217
226
 
@@ -232,11 +241,30 @@ The secret key is shown **once** at creation. Store it in a secrets manager (Vau
232
241
 
233
242
  ## Environments
234
243
 
235
- | Environment | Base URL |
236
- |-------------|----------|
237
- | `production` | `https://api.certysign.io` |
238
- | `staging` | `https://api-staging.certysign.io` |
239
- | `development` | `http://localhost:8000` |
244
+ | Environment | Base URL | TSA URL |
245
+ |-------------|----------|----------|
246
+ | `production` | `https://core.certysign.io` | `https://tsa.certysign.io` |
247
+ | `staging` | `https://service.certysign.io` | `https://tsa-staging.certysign.io` |
248
+ | `development` | `http://localhost:8000` | `http://localhost:5015` |
249
+
250
+ The **TSA URL** is resolved automatically from your environment — no manual configuration needed. Every PDF signature includes an RFC 3161 timestamp proving when the signature was created. Override with the `tsaUrl` constructor option if needed.
251
+
252
+ ```js
253
+ // TSA is automatic — just set your environment
254
+ const client = new CertySignClient({
255
+ publicKey: process.env.CERTYSIGN_PUBLIC_KEY,
256
+ secretKey: process.env.CERTYSIGN_SECRET_KEY,
257
+ environment: 'production' // TSA → https://tsa.certysign.io (automatic)
258
+ });
259
+
260
+ // Or override TSA URL explicitly
261
+ const client = new CertySignClient({
262
+ publicKey: process.env.CERTYSIGN_PUBLIC_KEY,
263
+ secretKey: process.env.CERTYSIGN_SECRET_KEY,
264
+ environment: 'production',
265
+ tsaUrl: 'https://custom-tsa.example.com' // Custom TSA endpoint
266
+ });
267
+ ```
240
268
 
241
269
  ---
242
270
 
@@ -259,14 +287,15 @@ const result = await client.sign.hashAndSign({
259
287
  location: 'Nairobi, Kenya', // Optional
260
288
  metadata: { claimId: '...' } // Optional
261
289
  });
262
- // result.data.signature — base64-encoded CMS/PKCS#7 signature
290
+ // result.data.signature — base64-encoded raw RSA signature
263
291
  // result.data.documentHash — hex hash of the document
264
292
  // result.data.hashAlgorithm — 'sha256' | 'sha384' | 'sha512'
265
293
  // result.data.algorithm — 'SHA256withRSA' etc.
266
- // result.data.certificate.pem — signer certificate PEM
267
- // result.data.certificate.serialNumber
268
- // result.data.certificate.chain — full trust chain PEM
269
- // result.data.signedAt — ISO 8601 timestamp
294
+ // result.data.certificate — signer certificate PEM string
295
+ // result.data.chain — full trust chain PEM string
296
+ // result.data.certSerialNumber — certificate serial (hex)
297
+ // result.data.certFingerprint — SHA-256 fingerprint (hex)
298
+ // result.data.timestamp — ISO 8601 timestamp
270
299
  ```
271
300
 
272
301
  #### `signHash(options)` — Sign a pre-computed hash
@@ -300,7 +329,9 @@ const result = await client.sign.batchHashAndSign({
300
329
  // result.data.results[] — array of signed results
301
330
  // result.data.results[].documentHash
302
331
  // result.data.results[].signature
303
- // result.data.certificate.pem — shared certificate for the batch
332
+ // result.data.certificate — shared certificate PEM for the batch
333
+ // result.data.certSerialNumber — certificate serial (hex)
334
+ // result.data.chain — chain PEM
304
335
  ```
305
336
 
306
337
  #### `batchSignHashes(options)` — Sign multiple pre-computed hashes
@@ -535,75 +566,99 @@ const results = await client.hasher.hashFiles([
535
566
 
536
567
  ### `client.embedder` — Local Signature Embedding
537
568
 
538
- Embeds CMS/PKCS#7 signatures into documents **locally** on your machine. No data is sent to CertySign during embedding.
569
+ Embeds CMS/PKCS#7 signatures into documents **locally** on your machine. PDF embedding creates cryptographically valid PAdES signatures recognised by Adobe Reader and Foxit.
570
+
571
+ #### `embedInPdf(pdfBuffer, options)` — Embed PAdES signature into PDF
572
+
573
+ Creates a visual signature stamp and a proper PDF `/Sig` dictionary with PKCS#7/CMS
574
+ cryptographic embedding. The resulting PDF passes validation in Adobe Reader, Foxit
575
+ Reader, and other PDF signature validators.
539
576
 
540
- #### `embedInPdf(pdfBuffer, options)` — Embed signature into PDF
577
+ **Recommended: `signCallback` approach (PAdES Baseline B-T)**
541
578
 
542
- Creates a visual signature stamp matching the CertySign platform and embeds CMS metadata into the PDF. Supports **multi-recipient** signing — each recipient gets their own visual stamp, stacked from the bottom of the page upward.
579
+ The `signCallback` approach produces cryptographically valid PAdES B-T signatures
580
+ with embedded RFC 3161 timestamps:
543
581
 
544
- **Single signer:**
582
+ 1. The embedder prepares the PDF with visual stamp and an empty `/Sig` placeholder
583
+ 2. It computes the SHA-256 hash of the PDF's ByteRange regions
584
+ 3. Your `signCallback` sends that hash to CertySign's HSM for signing
585
+ 4. The embedder fetches an RFC 3161 timestamp from the TSA (automatic)
586
+ 5. The embedder builds a PKCS#7 SignedData structure with the timestamp as an unsigned attribute and patches it into `/Contents`
545
587
 
546
588
  ```js
547
589
  const signedPdf = await client.embedder.embedInPdf(pdfBuffer, {
548
- signature: 'base64-cms-signature', // Required
549
- signerEmail: 'ndolimathews@gmail.com', // Recommended — shown on stamp
550
- signerName: 'Mathews Ndoli', // Fallback if no email
551
- certSerialNumber: '9EF9C8E88478FC08C6759942274EB8A2', // Optional
552
- certificate: '-----BEGIN CERTIFICATE-----...', // Optional — PEM
553
- chain: '-----BEGIN CERTIFICATE-----...', // Optional — chain PEM
554
- documentHash: 'a3f2b8c1...', // Optional — hex hash
555
- hashAlgorithm: 'sha256', // Optional
556
- algorithm: 'SHA256withRSA', // Optional
590
+ signerEmail: 'ndolimathews@gmail.com', // Shown on visual stamp
591
+ signerName: 'Mathews Ndoli', // Fallback if no email
592
+ certSerialNumber: '9EF9C8E88478FC08C6759942274EB8A2', // Shown on visual stamp
593
+ reason: 'Document approval', // Shown on stamp + /Sig dict
594
+ location: 'Nairobi, Kenya', // Optional
595
+ signCallback: async (byteRangeHash) => {
596
+ // byteRangeHash is a hex-encoded SHA-256 of the ByteRange regions
597
+ return client.sign.signHash({
598
+ documentHash: byteRangeHash,
599
+ hashAlgorithm: 'sha256',
600
+ fileName: 'document.pdf'
601
+ });
602
+ // Must return: { data: { signature, certificate, chain, certSerialNumber } }
603
+ }
557
604
  });
558
- // Returns: Buffer — signed PDF
605
+ // Returns: Buffer — PAdES-signed PDF
559
606
  fs.writeFileSync('./signed.pdf', signedPdf);
560
607
  ```
561
608
 
562
- **Multi-recipient (2+ signers):**
609
+ **Alternative: Pre-computed signature (legacy)**
610
+
611
+ You can pass a pre-computed `signature` instead of `signCallback`. The signature
612
+ will be embedded in a PKCS#7 structure, but since it was computed over the original
613
+ document hash (not the ByteRange), PDF readers may not fully validate it.
563
614
 
564
615
  ```js
565
616
  const signedPdf = await client.embedder.embedInPdf(pdfBuffer, {
566
- signatures: [
567
- {
568
- signature: 'base64-cms-sig-cfo',
569
- recipientEmail: 'cfo@company.co.ke',
570
- recipientName: 'Jane Mwangi',
571
- certSerialNumber: 'A1B2C3D4E5F6...',
572
- signatureAlgorithm: 'SHA256withRSA',
573
- signedAt: '2026-03-13T11:07:28.927Z'
574
- },
575
- {
576
- signature: 'base64-cms-sig-ceo',
577
- recipientEmail: 'ceo@company.co.ke',
578
- recipientName: 'James Otieno',
579
- certSerialNumber: 'F6E5D4C3B2A1...',
580
- signatureAlgorithm: 'SHA256withRSA',
581
- signedAt: '2026-03-13T11:12:45.000Z'
582
- }
583
- ],
584
- documentHash: 'a3f2b8c1...',
585
- hashAlgorithm: 'sha256'
617
+ signature: result.data.signature, // Base64 raw RSA signature
618
+ certificate: result.data.certificate, // PEM string
619
+ chain: result.data.chain, // PEM chain string
620
+ certSerialNumber: result.data.certSerialNumber,
621
+ signerEmail: 'ndolimathews@gmail.com',
622
+ reason: 'Approval'
623
+ });
624
+ ```
625
+
626
+ **Signature Position:**
627
+
628
+ ```js
629
+ const signedPdf = await client.embedder.embedInPdf(pdfBuffer, {
630
+ // ... signCallback or signature ...
631
+ signaturePosition: {
632
+ page: 1, // Page number (1-indexed, default: last page)
633
+ x: 20, // X offset from left (default: 20)
634
+ y: 20, // Y offset from bottom (default: 20)
635
+ width: 260 // Stamp width in points (default: 260)
636
+ }
586
637
  });
587
638
  ```
588
639
 
589
- Each signer gets a visual stamp matching the CertySign platform format:
640
+ Each signature gets a visual stamp matching the CertySign platform format:
590
641
 
591
642
  ```
592
- ┌──────────────────────────────────────┐
593
- │ Digitally signed by: cfo@company.co.ke│
594
- │ Date: 2026-03-13T11:07:28.927Z │
595
- │ Certificate: A1B2C3D4E5F6... │
596
- │ Standard: PAdES Baseline B-B │
597
- └──────────────────────────────────────┘
598
- ┌──────────────────────────────────────┐
599
- │ Digitally signed by: ceo@company.co.ke│
600
- │ Date: 2026-03-13T11:12:45.000Z │
601
- │ Certificate: F6E5D4C3B2A1... │
602
- │ Standard: PAdES Baseline B-B │
603
- └──────────────────────────────────────┘
643
+ ┌──────────────────────────────────────────┐
644
+ │ Digitally signed by: ndolimathews@gmail.com│
645
+ │ Date: 2026-03-16T14:30:00.000Z │
646
+ │ Certificate: 9EF9C8E88478FC08C6759942 │
647
+ │ Standard: PAdES Baseline B-T │
648
+ │ Timestamp: RFC 3161 (TSA) │
649
+ └──────────────────────────────────────────┘
604
650
  ```
605
651
 
606
- Multiple stamps auto-stack from the bottom-left of each page upward. PDF metadata includes per-signer entries (`CertySign-Signature-0`, `CertySign-Signer-0`, `CertySign-CertSerial-0`, etc.).
652
+ **PDF Signature Structure (what gets created):**
653
+
654
+ | PDF Object | Description |
655
+ |------------|-------------|
656
+ | `/Sig` dictionary | `Filter: Adobe.PPKLite`, `SubFilter: adbe.pkcs7.detached` |
657
+ | `/ByteRange` | Byte offsets of signed regions (everything except `/Contents` hex) |
658
+ | `/Contents` | DER-encoded PKCS#7 SignedData (hex, zero-padded to 8192 bytes) |
659
+ | Widget annotation | Links visual stamp rectangle to the `/Sig` dictionary |
660
+ | AcroForm | `SigFlags: 3` (SignaturesExist \| AppendOnly) |
661
+ | Certificates | Signing cert + intermediate CA + root CA embedded in PKCS#7 |
607
662
 
608
663
  #### `embedInXml(xmlString, options)` — Embed XMLDSig signature
609
664
 
@@ -815,14 +870,12 @@ Returns the HSM-backed certificate used for hash signing operations.
815
870
 
816
871
  ```js
817
872
  const { data } = await client.certificates.getActive();
818
- // data.certificate.serialNumber
819
- // data.certificate.fingerprint
820
- // data.certificate.pem — PEM certificate
821
- // data.certificate.chain — full trust chain PEM
822
- // data.certificate.validFrom
823
- // data.certificate.validUntil
824
- // data.certificate.subject — { commonName, organization, ... }
825
- // data.certificate.trustSettings — signing capabilities
873
+ // data.serialNumber
874
+ // data.fingerprint
875
+ // data.algorithm — e.g. 'RSA-2048'
876
+ // data.validFrom
877
+ // data.validUntil
878
+ // data.subject — { commonName, organization, ... }
826
879
  ```
827
880
 
828
881
  #### `issue(options)` — Issue a per-document certificate
@@ -990,7 +1043,7 @@ Legacy methods: `quickSign()`, `batchSign()`, `verifyById()`, `verifyDocument()`
990
1043
 
991
1044
  ## Complete Examples
992
1045
 
993
- ### PDF: Hash → Sign → Embed
1046
+ ### PDF: PAdES Sign with signCallback (Recommended)
994
1047
 
995
1048
  ```js
996
1049
  const fs = require('fs');
@@ -1003,30 +1056,27 @@ const client = new CertySignClient({
1003
1056
 
1004
1057
  async function signPdf(filePath) {
1005
1058
  const pdf = fs.readFileSync(filePath);
1059
+ const cert = await client.certificates.getActive();
1006
1060
 
1007
- // Hash & sign — document stays local
1008
- const { data } = await client.sign.hashAndSign({
1009
- document: pdf,
1010
- fileName: 'contract.pdf',
1011
- signerName: 'Legal Department',
1012
- reason: 'Contract execution'
1013
- });
1014
-
1015
- // Embed signature into PDF locally
1016
1061
  const signed = await client.embedder.embedInPdf(pdf, {
1017
- signature: data.signature,
1018
- certificate: data.certificate.pem,
1019
- certSerialNumber: data.certificate.serialNumber,
1020
- chain: data.certificate.chain,
1021
- signerName: 'Legal Department',
1022
- reason: 'Contract execution',
1023
- documentHash: data.documentHash,
1024
- hashAlgorithm: data.hashAlgorithm,
1025
- algorithm: data.algorithm
1062
+ certSerialNumber: cert.data?.serialNumber,
1063
+ signerName: 'Legal Department',
1064
+ signerEmail: 'legal@company.co.ke',
1065
+ reason: 'Contract execution',
1066
+ location: 'Nairobi, Kenya',
1067
+ signCallback: async (byteRangeHash) => {
1068
+ return client.sign.signHash({
1069
+ documentHash: byteRangeHash,
1070
+ hashAlgorithm: 'sha256',
1071
+ fileName: filePath.split('/').pop(),
1072
+ reason: 'Contract execution'
1073
+ });
1074
+ }
1026
1075
  });
1027
1076
 
1028
- fs.writeFileSync(filePath.replace('.pdf', '-signed.pdf'), signed);
1029
- console.log('Signed:', data.certificate.serialNumber);
1077
+ const outPath = filePath.replace('.pdf', '-signed.pdf');
1078
+ fs.writeFileSync(outPath, signed);
1079
+ console.log('PAdES signed:', outPath);
1030
1080
  }
1031
1081
  ```
1032
1082
 
@@ -1044,12 +1094,12 @@ async function signXml(filePath) {
1044
1094
  });
1045
1095
 
1046
1096
  const signedXml = client.embedder.embedInXml(xml, {
1047
- signature: data.signature,
1048
- certificate: data.certificate.pem,
1049
- certSerialNumber: data.certificate.serialNumber,
1050
- documentHash: hash,
1051
- hashAlgorithm: algorithm,
1052
- algorithm: data.algorithm
1097
+ signature: data.signature,
1098
+ certificate: data.certificate, // PEM string
1099
+ certSerialNumber: data.certSerialNumber,
1100
+ documentHash: hash,
1101
+ hashAlgorithm: algorithm,
1102
+ algorithm: data.algorithm
1053
1103
  });
1054
1104
 
1055
1105
  fs.writeFileSync(filePath.replace('.xml', '-signed.xml'), signedXml);
@@ -1059,23 +1109,23 @@ async function signXml(filePath) {
1059
1109
  ### JSON: Hash → Sign → Envelope
1060
1110
 
1061
1111
  ```js
1062
- async function signJson(data) {
1063
- const json = JSON.stringify(data);
1112
+ async function signJson(inputData) {
1113
+ const json = JSON.stringify(inputData);
1064
1114
  const { hash, algorithm } = client.hasher.hash(Buffer.from(json), 'sha256');
1065
1115
 
1066
- const { data: sig } = await client.sign.signHash({
1116
+ const { data } = await client.sign.signHash({
1067
1117
  documentHash: hash,
1068
1118
  hashAlgorithm: algorithm,
1069
1119
  fileName: 'payload.json'
1070
1120
  });
1071
1121
 
1072
- return client.embedder.embedInJson(data, {
1073
- signature: sig.signature,
1074
- certificate: sig.certificate.pem,
1075
- certSerialNumber: sig.certificate.serialNumber,
1076
- documentHash: hash,
1077
- hashAlgorithm: algorithm,
1078
- algorithm: sig.algorithm
1122
+ return client.embedder.embedInJson(inputData, {
1123
+ signature: data.signature,
1124
+ certificate: data.certificate, // PEM string
1125
+ certSerialNumber: data.certSerialNumber,
1126
+ documentHash: hash,
1127
+ hashAlgorithm: algorithm,
1128
+ algorithm: data.algorithm
1079
1129
  });
1080
1130
  }
1081
1131
  ```
@@ -1099,15 +1149,20 @@ const { data } = await client.sign.batchHashAndSign({
1099
1149
  reason: 'Monthly provider reimbursement'
1100
1150
  });
1101
1151
 
1102
- // Embed signatures locally
1152
+ // Embed PAdES signatures locally using signCallback for each
1103
1153
  for (let i = 0; i < data.results.length; i++) {
1104
1154
  const signed = await client.embedder.embedInPdf(documents[i].document, {
1105
- signature: data.results[i].signature,
1106
- certificate: data.certificate.pem,
1107
- certSerialNumber: data.certificate.serialNumber,
1108
- documentHash: data.results[i].documentHash,
1109
- hashAlgorithm: data.results[i].hashAlgorithm,
1110
- algorithm: data.results[i].algorithm
1155
+ certSerialNumber: data.certSerialNumber,
1156
+ signerName: 'NHIF Finance System',
1157
+ signerEmail: 'finance@nhif.or.ke',
1158
+ reason: 'Monthly provider reimbursement',
1159
+ signCallback: async (byteRangeHash) => {
1160
+ return client.sign.signHash({
1161
+ documentHash: byteRangeHash,
1162
+ hashAlgorithm: 'sha256',
1163
+ fileName: files[i]
1164
+ });
1165
+ }
1111
1166
  });
1112
1167
  fs.writeFileSync(`./invoices/signed/${files[i]}`, signed);
1113
1168
  }
@@ -1256,6 +1311,61 @@ For batch workloads, use `batchHashAndSign()` or `batchSignHashes()` to sign up
1256
1311
 
1257
1312
  ## Migration from v1
1258
1313
 
1314
+ ### What's New in v2.2.0
1315
+
1316
+ | Feature | Description |
1317
+ |---------|-------------|
1318
+ | **PAdES Baseline B-T** | Every PDF signature now includes an RFC 3161 timestamp from CertySign's HSM-backed TSA |
1319
+ | **Automatic TSA URL** | TSA URL is resolved from your environment — no manual `tsaUrl` parameter needed |
1320
+ | **HSM-backed TSA key** | Timestamps are signed with a dedicated HSM key (RSA-3072, FIPS 140-2 Level 3) |
1321
+ | **OTP email delivery** | OTP emails for signing sessions are now fully operational |
1322
+ | **Audit blockchain anchoring** | Signing session events (`SIGNING_SESSION_RECIPIENT_SIGNED`, `SIGNING_OTP_VERIFIED`) are now archived to WORM storage and included in daily Merkle tree anchoring |
1323
+
1324
+ ### What's New in v2.1.0
1325
+
1326
+ | Feature | Description |
1327
+ |---------|-------------|
1328
+ | **PAdES cryptographic embedding** | `embedInPdf()` now creates proper PDF `/Sig` dictionaries with PKCS#7/CMS that Adobe Reader and Foxit validate |
1329
+ | **`signCallback` option** | Pass an async callback to `embedInPdf()` — the embedder computes ByteRange hash, your callback signs it via HSM |
1330
+ | **PKCS#7 SignedData** | Full CMS ASN.1 structure: signing cert + chain embedded, SHA-256 digest algorithm, detached content |
1331
+ | **ByteRange signing** | Signature covers the actual PDF ByteRange regions, not just the original document hash |
1332
+ | **`node-forge` dependency** | Added for ASN.1/PKCS#7 construction |
1333
+
1334
+ #### Constructor Change (v2.2.0)
1335
+
1336
+ The `tsaUrl` parameter is now optional. TSA is resolved automatically:
1337
+
1338
+ ```js
1339
+ // v2.1.0 — manual TSA URL required for timestamps
1340
+ const client = new CertySignClient({
1341
+ publicKey, secretKey,
1342
+ tsaUrl: 'http://localhost:5015'
1343
+ });
1344
+
1345
+ // v2.2.0 — TSA is automatic
1346
+ const client = new CertySignClient({
1347
+ publicKey, secretKey,
1348
+ environment: 'production' // TSA URL auto-resolved
1349
+ });
1350
+ ```
1351
+
1352
+ #### Response Shape Change (v2.1.0)
1353
+
1354
+ The signing API response has a flat structure (not nested):
1355
+
1356
+ ```js
1357
+ // v2.0.0 (incorrect docs):
1358
+ result.data.certificate.pem // ❌ was never an object
1359
+ result.data.certificate.serialNumber // ❌
1360
+ result.data.certificate.chain // ❌
1361
+
1362
+ // v2.1.0 (correct — always was this shape):
1363
+ result.data.certificate // ✅ PEM string directly
1364
+ result.data.certSerialNumber // ✅ hex serial string
1365
+ result.data.chain // ✅ PEM chain string
1366
+ result.data.certFingerprint // ✅ SHA-256 hex fingerprint
1367
+ ```
1368
+
1259
1369
  ### Breaking Changes in v2.0.0
1260
1370
 
1261
1371
  | v1 | v2 | Notes |
@@ -1325,8 +1435,83 @@ OCSP: `https://pki.certysign.io/ocsp`
1325
1435
  - **Store the private key** from `certificates.issue()` response in a HSM or secrets manager — CertySign does not retain it
1326
1436
  - **Cache CRL / OCSP responses** up to `nextUpdate` to reduce latency in HIE systems
1327
1437
  - **Verify hashes** after embedding — re-hash the original document to confirm integrity
1328
- - **Secure OTP delivery** — OTPs are single-use, expire in 10 minutes, and lock after 5 failed attempts
1329
- - **Signing tokens** — 30-minute expiry, cryptographically random, single-use
1438
+ - **Secure OTP delivery** — OTPs are single-use, expire in 10 minutes, and lock after 5 failed attempts. OTP codes are stored as SHA-256 hashes and verified with constant-time comparison
1439
+ - **Signing tokens** — 30-minute expiry, cryptographically random (32 bytes), single-use, stored as SHA-256 hashes
1440
+ - **Timestamps** — every signature includes an RFC 3161 timestamp from an HSM-backed TSA, proving when the document was signed
1441
+ - **Audit immutability** — signing events are archived to WORM object storage and anchored via daily Merkle trees
1442
+
1443
+ ---
1444
+
1445
+ ## Timestamp Authority (TSA)
1446
+
1447
+ All PDF signatures include an **RFC 3161 timestamp** that cryptographically proves
1448
+ when the document was signed. The timestamp is issued by CertySign's HSM-backed
1449
+ Timestamp Authority and embedded as an unsigned attribute in the PKCS#7 SignedData.
1450
+
1451
+ ### How It Works
1452
+
1453
+ | Step | What happens |
1454
+ |------|--------------|
1455
+ | 1 | The PDF ByteRange hash is signed by the tenant's HSM key |
1456
+ | 2 | The signature bytes are hashed (SHA-256) |
1457
+ | 3 | The hash is sent to the TSA (`POST /v1/tsa/json`) |
1458
+ | 4 | The TSA signs the hash with its dedicated HSM key and returns an RFC 3161 TimeStampToken |
1459
+ | 5 | The TimeStampToken is added as an unsigned attribute (OID `1.2.840.113549.1.9.16.2.14`) in the PKCS#7 |
1460
+
1461
+ ### TSA URL Resolution
1462
+
1463
+ The TSA URL is resolved **automatically** from your environment:
1464
+
1465
+ ```js
1466
+ // No tsaUrl needed — resolved from environment
1467
+ const client = new CertySignClient({
1468
+ publicKey: '...',
1469
+ secretKey: '...',
1470
+ environment: 'production' // → https://tsa.certysign.io
1471
+ });
1472
+
1473
+ console.log(client.tsaUrl); // https://tsa.certysign.io
1474
+ ```
1475
+
1476
+ | Environment | TSA URL |
1477
+ |-------------|----------|
1478
+ | `production` | `https://tsa.certysign.io` |
1479
+ | `staging` | `https://tsa-staging.certysign.io` |
1480
+ | `development` | `http://localhost:5015` |
1481
+ | `test` | `http://localhost:5015` |
1482
+
1483
+ Override with `tsaUrl` if you run a custom TSA:
1484
+
1485
+ ```js
1486
+ const client = new CertySignClient({
1487
+ publicKey: '...', secretKey: '...',
1488
+ tsaUrl: 'https://custom-tsa.example.com'
1489
+ });
1490
+ ```
1491
+
1492
+ ### PAdES Compliance Levels
1493
+
1494
+ | Standard | Timestamp | When |
1495
+ |----------|-----------|------|
1496
+ | **PAdES Baseline B-T** | RFC 3161 embedded | TSA URL available (default) |
1497
+ | **PAdES Baseline B-B** | None | TSA URL is `null` (manually disabled) |
1498
+
1499
+ Since v2.2.0, all environments have TSA configured by default, so every signature
1500
+ is **PAdES Baseline B-T** unless you explicitly pass `tsaUrl: null`.
1501
+
1502
+ ### Verifying Timestamps
1503
+
1504
+ The timestamp token is embedded in the PKCS#7 SignedData as an unsigned attribute.
1505
+ PDF validators (Adobe Reader, Foxit) display:
1506
+
1507
+ ```
1508
+ Signature is timestamped
1509
+ The signature includes an embedded timestamp.
1510
+ Timestamp time: 2026-03-17T09:05:29.000Z
1511
+ ```
1512
+
1513
+ To verify programmatically, parse the PKCS#7 ASN.1 and check for the unsigned
1514
+ attribute with OID `1.2.840.113549.1.9.16.2.14` (id-smime-aa-timeStampToken).
1330
1515
 
1331
1516
  ---
1332
1517
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@certysign/sdk",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Official Node.js SDK for CertySign — hash-based digital signing, X.509 certificates, and PKI services. Documents never leave your system.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/index.js CHANGED
@@ -84,7 +84,8 @@ class CertySignClient {
84
84
  environment = 'production',
85
85
  timeout,
86
86
  retries,
87
- debug = false
87
+ debug = false,
88
+ tsaUrl,
88
89
  } = options;
89
90
 
90
91
  if (!publicKey) throw new Error('CertySignClient: publicKey is required');
@@ -118,11 +119,15 @@ class CertySignClient {
118
119
  this.sessions = new SigningSessionResource(this._http); // Multi-recipient signing sessions
119
120
  this.dashboard = new DashboardResource(this._http); // SDK usage analytics
120
121
  this.hasher = new DocumentHasher(); // Local document hashing
121
- this.embedder = new SignatureEmbedder(); // Local signature embedding
122
+
123
+ // TSA URL: explicit > environment-derived
124
+ const resolvedTsaUrl = tsaUrl ?? CertySignClient.TSA_URLS[environment] ?? null;
125
+ this.embedder = new SignatureEmbedder({ tsaUrl: resolvedTsaUrl }); // PAdES-T when TSA URL available
122
126
 
123
127
  this.publicKey = publicKey;
124
128
  this.environment = environment;
125
129
  this.baseUrl = resolvedBaseUrl;
130
+ this.tsaUrl = resolvedTsaUrl;
126
131
  }
127
132
 
128
133
  /**
@@ -138,6 +143,19 @@ class CertySignClient {
138
143
  };
139
144
  }
140
145
 
146
+ /**
147
+ * Environment → TSA URL mapping.
148
+ * Override via the `tsaUrl` constructor option.
149
+ */
150
+ static get TSA_URLS() {
151
+ return {
152
+ production: 'https://tsa.certysign.io',
153
+ staging: 'https://tsa-staging.certysign.io',
154
+ development: 'http://localhost:5015',
155
+ test: 'http://localhost:5015'
156
+ };
157
+ }
158
+
141
159
  /**
142
160
  * Verify that the API key is valid and return the key metadata.
143
161
  * Useful as a "ping" / health check at startup.
@@ -29,10 +29,19 @@ 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 (8192 bytes = 16384 hex chars)
33
- const SIG_PLACEHOLDER_LENGTH = 16384;
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;
34
35
 
35
36
  class SignatureEmbedder {
37
+ /**
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
+
36
45
  /**
37
46
  * Embed one or more digital signatures into a PDF document with
38
47
  * proper PKCS#7/CMS cryptographic embedding (PAdES compliant).
@@ -94,21 +103,48 @@ class SignatureEmbedder {
94
103
  if (!entry) throw new Error('embedInPdf: at least one signature entry is required');
95
104
 
96
105
  const signerEmail = entry.signerEmail || entry.recipientEmail || entry.signerName || 'CertySign';
97
- const certSerial = entry.certSerialNumber || '';
106
+ let certSerial = entry.certSerialNumber || '';
98
107
  const signDate = entry.timestamp ? new Date(entry.timestamp) :
99
108
  entry.signedAt ? new Date(entry.signedAt) : new Date();
100
109
  const reason = entry.reason || 'Digital signature';
101
110
  const location = entry.location || '';
102
- const standard = entry.standard || 'PAdES Baseline B-B';
103
111
  const certPem = entry.certificate || null;
104
112
  const chainPem = entry.chain || null;
105
113
  const signCallback = entry.signCallback || options.signCallback || null;
106
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');
107
117
 
108
118
  if (!signCallback && !precomputedSig) {
109
119
  throw new Error('embedInPdf: either signCallback or signature is required');
110
120
  }
111
121
 
122
+ // ── Auto-resolve cert serial from PEM if not explicitly provided ──
123
+ if (!certSerial && certPem) {
124
+ try {
125
+ const parsedCert = forge.certificateFromPem(certPem);
126
+ certSerial = (parsedCert.serialNumber || '').toUpperCase();
127
+ } catch { /* ignore parse errors — stamp will show empty cert serial */ }
128
+ }
129
+
130
+ // ── signCallback pre-flight: discover cert serial before drawing stamp ──
131
+ // When using signCallback without a known cert serial, we call the callback
132
+ // with a dummy hash to learn the certificate. Then we re-call with the real
133
+ // ByteRange hash below. This ensures the visual stamp shows the cert serial.
134
+ let preflightResult = null;
135
+ if (signCallback && !certSerial) {
136
+ const dummyHash = '0'.repeat(64); // SHA-256 dummy
137
+ preflightResult = await signCallback(dummyHash);
138
+ const preflightCertPem = preflightResult.data?.certificate || preflightResult.certificate;
139
+ certSerial = preflightResult.data?.certSerialNumber || preflightResult.certSerialNumber || '';
140
+ if (!certSerial && preflightCertPem) {
141
+ try {
142
+ const parsedCert = forge.certificateFromPem(preflightCertPem);
143
+ certSerial = (parsedCert.serialNumber || '').toUpperCase();
144
+ } catch { /* ignore */ }
145
+ }
146
+ }
147
+
112
148
  // ── Determine target page ──
113
149
  const pos = entry.signaturePosition || {};
114
150
  const pageIdx = pos.page
@@ -273,10 +309,19 @@ class SignatureEmbedder {
273
309
  rawSigBase64 = precomputedSig;
274
310
  }
275
311
 
312
+ // ── Phase 4.5: Fetch TSA timestamp token (PAdES B-B → PAdES B-T) ──
313
+ let timestampTokenAsn1 = null;
314
+ if (tsaUrl) {
315
+ // Hash the raw signature value — the timestamp proves the signature existed at this time
316
+ const rawSigBytes = Buffer.from(rawSigBase64, 'base64');
317
+ const sigHash = crypto.createHash('sha256').update(rawSigBytes).digest('hex');
318
+ timestampTokenAsn1 = await _fetchTimestamp(forge, tsaUrl, sigHash);
319
+ }
320
+
276
321
  // ── Phase 5: Build PKCS#7 and patch into /Contents ──
277
322
  const p7 = _buildPkcs7(forge, rawSigBase64, sigCertPem, sigChainPem, signDate, {
278
323
  signerEmail, reason, location, certSerial
279
- });
324
+ }, timestampTokenAsn1);
280
325
  const p7Der = forge.asn1.toDer(p7).getBytes();
281
326
  const p7Hex = Buffer.from(p7Der, 'binary').toString('hex');
282
327
 
@@ -457,8 +502,12 @@ function _xmlEscape(str) {
457
502
  *
458
503
  * The HSM already produced the raw RSA signature over the document hash.
459
504
  * We wrap it into a CMS SignedData container so PDF readers recognise it.
505
+ *
506
+ * When timestampTokenAsn1 is provided, it is added as an unsigned attribute
507
+ * (id-smime-aa-timeStampToken, OID 1.2.840.113549.1.9.16.2.14) in SignerInfo,
508
+ * upgrading the signature from PAdES B-B to PAdES B-T.
460
509
  */
461
- function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta) {
510
+ function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta, timestampTokenAsn1) {
462
511
  const rawSigBytes = forge.util.decode64(rawSigBase64);
463
512
 
464
513
  // Parse the signing certificate
@@ -538,7 +587,20 @@ function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta) {
538
587
  ]),
539
588
  // signature
540
589
  forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false,
541
- rawSigBytes)
590
+ rawSigBytes),
591
+ // unauthenticatedAttributes [1] IMPLICIT (PAdES-T timestamp token)
592
+ ...(timestampTokenAsn1 ? [
593
+ forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 1, true, [
594
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
595
+ // id-smime-aa-timeStampToken (1.2.840.113549.1.9.16.2.14)
596
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false,
597
+ forge.asn1.oidToDer('1.2.840.113549.1.9.16.2.14').getBytes()),
598
+ forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SET, true, [
599
+ timestampTokenAsn1
600
+ ])
601
+ ])
602
+ ])
603
+ ] : [])
542
604
  ]
543
605
  );
544
606
  signerInfoSets.push(signerInfo);
@@ -600,6 +662,74 @@ function _pdfDate(date) {
600
662
  `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}+00'00'`;
601
663
  }
602
664
 
665
+ /**
666
+ * Fetch an RFC 3161 timestamp token from the TSA Service.
667
+ *
668
+ * Calls the TSA JSON endpoint with the SHA-256 hash of the signature value.
669
+ * Parses the TimeStampResp and extracts the TimeStampToken (ContentInfo).
670
+ *
671
+ * @param {Object} forge - node-forge instance
672
+ * @param {string} tsaUrl - Base URL of the TSA Service (e.g. http://localhost:5015)
673
+ * @param {string} sigHashHex - SHA-256 hash of the signature value (hex)
674
+ * @returns {Object} ASN.1 ContentInfo (TimeStampToken) for embedding as unsigned attribute
675
+ */
676
+ async function _fetchTimestamp(forge, tsaUrl, sigHashHex) {
677
+ const http = require('http');
678
+ const https = require('https');
679
+ const url = new URL('/v1/tsa/json', tsaUrl);
680
+
681
+ const body = JSON.stringify({
682
+ hash: sigHashHex,
683
+ hashAlgorithm: 'sha-256',
684
+ mode: 'classical',
685
+ certReq: true,
686
+ });
687
+
688
+ const transport = url.protocol === 'https:' ? https : http;
689
+
690
+ const tsaResp = await new Promise((resolve, reject) => {
691
+ const req = transport.request(url, {
692
+ method: 'POST',
693
+ headers: {
694
+ 'Content-Type': 'application/json',
695
+ 'Content-Length': Buffer.byteLength(body),
696
+ },
697
+ timeout: 15000,
698
+ }, (res) => {
699
+ const chunks = [];
700
+ res.on('data', c => chunks.push(c));
701
+ res.on('end', () => {
702
+ try {
703
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
704
+ } catch (e) {
705
+ reject(new Error(`TSA response parse error: ${e.message}`));
706
+ }
707
+ });
708
+ });
709
+
710
+ req.on('error', e => reject(new Error(`TSA request failed: ${e.message}`)));
711
+ req.on('timeout', () => { req.destroy(); reject(new Error('TSA request timed out')); });
712
+ req.write(body);
713
+ req.end();
714
+ });
715
+
716
+ if (!tsaResp.success || !tsaResp.token) {
717
+ throw new Error(`TSA timestamp request failed: ${tsaResp.error || 'no token returned'}`);
718
+ }
719
+
720
+ // Parse TimeStampResp → extract TimeStampToken (ContentInfo)
721
+ const respDer = Buffer.from(tsaResp.token, 'base64');
722
+ const respAsn1 = forge.asn1.fromDer(forge.util.createBuffer(respDer));
723
+
724
+ // TimeStampResp ::= SEQUENCE { PKIStatusInfo, TimeStampToken OPTIONAL }
725
+ if (!respAsn1.value || respAsn1.value.length < 2) {
726
+ throw new Error('Invalid TimeStampResp: missing TimeStampToken');
727
+ }
728
+
729
+ // respAsn1.value[1] is the ContentInfo (TimeStampToken)
730
+ return respAsn1.value[1];
731
+ }
732
+
603
733
  /**
604
734
  * Patch the placeholder /Contents hex string in the saved PDF with actual PKCS#7 DER bytes.
605
735
  * Searches for the placeholder pattern and replaces it with the real signature.