@certysign/sdk 2.1.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.
- package/README.md +398 -213
- package/package.json +1 -1
- package/src/index.js +20 -2
- package/src/lib/SignatureEmbedder.js +110 -6
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
|
-
###
|
|
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
|
-
//
|
|
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
|
|
117
|
-
location: 'Nairobi, Kenya'
|
|
118
|
+
reason: 'Health claim approval'
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
//
|
|
121
|
+
// 2. Embed signature into PDF
|
|
121
122
|
const signedPdf = await client.embedder.embedInPdf(pdfBuffer, {
|
|
122
|
-
signature:
|
|
123
|
-
certificate:
|
|
124
|
-
certSerialNumber: result.data.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
│
|
|
180
|
-
│ │
|
|
181
|
-
│ │
|
|
182
|
-
│ │
|
|
183
|
-
│
|
|
184
|
-
│
|
|
185
|
-
│
|
|
186
|
-
│
|
|
187
|
-
│
|
|
188
|
-
│
|
|
189
|
-
│
|
|
190
|
-
│
|
|
191
|
-
│
|
|
192
|
-
│
|
|
193
|
-
│
|
|
194
|
-
│
|
|
195
|
-
│
|
|
196
|
-
│
|
|
197
|
-
│
|
|
198
|
-
│
|
|
199
|
-
│
|
|
200
|
-
│
|
|
201
|
-
│
|
|
202
|
-
│
|
|
203
|
-
│
|
|
204
|
-
│
|
|
205
|
-
│ │
|
|
206
|
-
│
|
|
207
|
-
│ │
|
|
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
|
|
214
|
-
**What stays local:** The original document, the
|
|
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://
|
|
238
|
-
| `staging` | `https://
|
|
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
|
|
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
|
|
267
|
-
// result.data.
|
|
268
|
-
// result.data.
|
|
269
|
-
// result.data.
|
|
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
|
|
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.
|
|
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
|
-
|
|
577
|
+
**Recommended: `signCallback` approach (PAdES Baseline B-T)**
|
|
541
578
|
|
|
542
|
-
|
|
579
|
+
The `signCallback` approach produces cryptographically valid PAdES B-T signatures
|
|
580
|
+
with embedded RFC 3161 timestamps:
|
|
543
581
|
|
|
544
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
640
|
+
Each signature gets a visual stamp matching the CertySign platform format:
|
|
590
641
|
|
|
591
642
|
```
|
|
592
|
-
|
|
593
|
-
│ Digitally signed by:
|
|
594
|
-
│ Date: 2026-03-
|
|
595
|
-
│ Certificate:
|
|
596
|
-
│ Standard: PAdES Baseline 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
|
-
|
|
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.
|
|
819
|
-
// data.
|
|
820
|
-
// data.
|
|
821
|
-
// data.
|
|
822
|
-
// data.
|
|
823
|
-
// data.
|
|
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:
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
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:
|
|
1048
|
-
certificate:
|
|
1049
|
-
certSerialNumber: data.
|
|
1050
|
-
documentHash:
|
|
1051
|
-
hashAlgorithm:
|
|
1052
|
-
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(
|
|
1063
|
-
const json = JSON.stringify(
|
|
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
|
|
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(
|
|
1073
|
-
signature:
|
|
1074
|
-
certificate:
|
|
1075
|
-
certSerialNumber:
|
|
1076
|
-
documentHash:
|
|
1077
|
-
hashAlgorithm:
|
|
1078
|
-
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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.
|
|
3
|
+
"version": "2.2.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
|
-
|
|
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 (
|
|
33
|
-
|
|
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).
|
|
@@ -99,11 +108,12 @@ class SignatureEmbedder {
|
|
|
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');
|
|
@@ -273,10 +283,19 @@ class SignatureEmbedder {
|
|
|
273
283
|
rawSigBase64 = precomputedSig;
|
|
274
284
|
}
|
|
275
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);
|
|
293
|
+
}
|
|
294
|
+
|
|
276
295
|
// ── Phase 5: Build PKCS#7 and patch into /Contents ──
|
|
277
296
|
const p7 = _buildPkcs7(forge, rawSigBase64, sigCertPem, sigChainPem, signDate, {
|
|
278
297
|
signerEmail, reason, location, certSerial
|
|
279
|
-
});
|
|
298
|
+
}, timestampTokenAsn1);
|
|
280
299
|
const p7Der = forge.asn1.toDer(p7).getBytes();
|
|
281
300
|
const p7Hex = Buffer.from(p7Der, 'binary').toString('hex');
|
|
282
301
|
|
|
@@ -457,8 +476,12 @@ function _xmlEscape(str) {
|
|
|
457
476
|
*
|
|
458
477
|
* The HSM already produced the raw RSA signature over the document hash.
|
|
459
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.
|
|
460
483
|
*/
|
|
461
|
-
function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta) {
|
|
484
|
+
function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta, timestampTokenAsn1) {
|
|
462
485
|
const rawSigBytes = forge.util.decode64(rawSigBase64);
|
|
463
486
|
|
|
464
487
|
// Parse the signing certificate
|
|
@@ -538,7 +561,20 @@ function _buildPkcs7(forge, rawSigBase64, certPem, chainPem, signDate, meta) {
|
|
|
538
561
|
]),
|
|
539
562
|
// signature
|
|
540
563
|
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false,
|
|
541
|
-
rawSigBytes)
|
|
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
|
+
] : [])
|
|
542
578
|
]
|
|
543
579
|
);
|
|
544
580
|
signerInfoSets.push(signerInfo);
|
|
@@ -600,6 +636,74 @@ function _pdfDate(date) {
|
|
|
600
636
|
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}+00'00'`;
|
|
601
637
|
}
|
|
602
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
|
+
|
|
603
707
|
/**
|
|
604
708
|
* Patch the placeholder /Contents hex string in the saved PDF with actual PKCS#7 DER bytes.
|
|
605
709
|
* Searches for the placeholder pattern and replaces it with the real signature.
|