@f-o-t/e-signature 1.2.8 → 1.2.10
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 +50 -0
- package/dist/index-jpks8xrw.js +541 -0
- package/dist/index-jpks8xrw.js.map +14 -0
- package/dist/index.js +14 -536
- package/dist/index.js.map +3 -8
- package/dist/plugins/react/index.js +85 -0
- package/dist/plugins/react/index.js.map +10 -0
- package/package.json +17 -3
package/README.md
CHANGED
|
@@ -16,6 +16,56 @@ bun add @f-o-t/e-signature
|
|
|
16
16
|
- RFC 3161 timestamp support
|
|
17
17
|
- QR code generation for signature verification
|
|
18
18
|
- Configurable DocMDP permissions for document modification control
|
|
19
|
+
- **Browser compatible** — no `Buffer` or Node-only APIs; runs in browsers, Edge Runtime, and Cloudflare Workers
|
|
20
|
+
|
|
21
|
+
## React Hook
|
|
22
|
+
|
|
23
|
+
### `useSignPdf(): UseSignPdfReturn`
|
|
24
|
+
|
|
25
|
+
React hook for client-side PDF signing. Import from `@f-o-t/e-signature/plugins/react` (requires `react >= 18` as a peer dependency).
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { useSignPdf } from "@f-o-t/e-signature/plugins/react";
|
|
29
|
+
|
|
30
|
+
function SignForm() {
|
|
31
|
+
const { sign, isSigning, isDone, isError, result, error, download, reset } = useSignPdf();
|
|
32
|
+
|
|
33
|
+
async function handleSubmit(pdfFile: File, p12File: File) {
|
|
34
|
+
try {
|
|
35
|
+
await sign({
|
|
36
|
+
pdf: pdfFile, // File, Blob, or Uint8Array
|
|
37
|
+
p12: p12File, // File, Blob, or Uint8Array
|
|
38
|
+
password: "secret",
|
|
39
|
+
options: { reason: "Approval", location: "São Paulo", policy: "pades-icp-brasil" },
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
// error state is also set on the hook
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isSigning) return <p>Signing…</p>;
|
|
47
|
+
if (isError) return <p>Error: {error?.message}</p>;
|
|
48
|
+
if (isDone) return <button onClick={() => download("signed.pdf")}>Download</button>;
|
|
49
|
+
return <button onClick={() => handleSubmit(myPdf, myCert)}>Sign</button>;
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Return value:**
|
|
54
|
+
|
|
55
|
+
| Property | Type | Description |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `status` | `"idle" \| "signing" \| "done" \| "error"` | Current lifecycle state |
|
|
58
|
+
| `isIdle` | `boolean` | No operation in progress |
|
|
59
|
+
| `isSigning` | `boolean` | Signing is running |
|
|
60
|
+
| `isDone` | `boolean` | Last sign call succeeded |
|
|
61
|
+
| `isError` | `boolean` | Last sign call failed |
|
|
62
|
+
| `result` | `Uint8Array \| null` | Signed PDF bytes (non-null when `isDone`) |
|
|
63
|
+
| `error` | `Error \| null` | Failure reason (non-null when `isError`) |
|
|
64
|
+
| `sign(input)` | `(SignInput) => Promise<Uint8Array \| undefined>` | Trigger signing; concurrent calls while signing are ignored (returns `undefined`) |
|
|
65
|
+
| `download(filename?)` | `(string?) => void` | Trigger browser download of signed PDF; no-op if not done |
|
|
66
|
+
| `reset()` | `() => void` | Return to idle, clearing result and error |
|
|
67
|
+
|
|
68
|
+
`pdf` and `p12` accept `File`, `Blob`, or `Uint8Array` — the hook converts `File`/`Blob` to `Uint8Array` automatically before calling `signPdf`.
|
|
19
69
|
|
|
20
70
|
## API
|
|
21
71
|
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __require = import.meta.require;
|
|
3
|
+
|
|
4
|
+
// src/icp-brasil.ts
|
|
5
|
+
import {
|
|
6
|
+
contextTag,
|
|
7
|
+
decodeDer,
|
|
8
|
+
encodeDer,
|
|
9
|
+
ia5String,
|
|
10
|
+
nullValue,
|
|
11
|
+
octetString,
|
|
12
|
+
oid,
|
|
13
|
+
sequence
|
|
14
|
+
} from "@f-o-t/asn1";
|
|
15
|
+
import { hash } from "@f-o-t/crypto";
|
|
16
|
+
var SHA256_OID = "2.16.840.1.101.3.4.2.1";
|
|
17
|
+
var SIGNING_CERTIFICATE_V2_OID = "1.2.840.113549.1.9.16.2.47";
|
|
18
|
+
var SIGNATURE_POLICY_OID = "1.2.840.113549.1.9.16.2.15";
|
|
19
|
+
var POLICY_CONFIG = {
|
|
20
|
+
OID: "2.16.76.1.7.1.11.1.1",
|
|
21
|
+
URL: "http://politicas.icpbrasil.gov.br/PA_PAdES_AD_RB_v1_1.der"
|
|
22
|
+
};
|
|
23
|
+
var SPQ_ETS_URI_OID = "1.2.840.113549.1.9.16.5.1";
|
|
24
|
+
var cachedPolicyData = null;
|
|
25
|
+
function clearPolicyCache() {
|
|
26
|
+
cachedPolicyData = null;
|
|
27
|
+
}
|
|
28
|
+
function buildSigningCertificateV2(certDer) {
|
|
29
|
+
const certHash = hash("sha256", certDer);
|
|
30
|
+
const hashAlgId = sequence(oid(SHA256_OID), nullValue());
|
|
31
|
+
const cert = decodeDer(certDer);
|
|
32
|
+
const tbsCert = cert.value[0];
|
|
33
|
+
const tbs = tbsCert.value;
|
|
34
|
+
let idx = 0;
|
|
35
|
+
if (tbs[0].class === "context" && tbs[0].tag === 0) {
|
|
36
|
+
idx = 1;
|
|
37
|
+
}
|
|
38
|
+
const serialNumber = tbs[idx];
|
|
39
|
+
const issuerName = tbs[idx + 2];
|
|
40
|
+
const generalName = contextTag(4, [issuerName]);
|
|
41
|
+
const generalNames = sequence(generalName);
|
|
42
|
+
const issuerSerial = sequence(generalNames, serialNumber);
|
|
43
|
+
const essCertIdV2 = sequence(hashAlgId, octetString(certHash), issuerSerial);
|
|
44
|
+
const signingCertV2 = sequence(sequence(essCertIdV2));
|
|
45
|
+
return encodeDer(signingCertV2);
|
|
46
|
+
}
|
|
47
|
+
async function downloadAndParsePolicyDocument() {
|
|
48
|
+
if (cachedPolicyData) {
|
|
49
|
+
return cachedPolicyData;
|
|
50
|
+
}
|
|
51
|
+
const response = await fetch(POLICY_CONFIG.URL);
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new SignaturePolicyError(`Failed to download signature policy: HTTP ${response.status}`);
|
|
54
|
+
}
|
|
55
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
56
|
+
const data = new Uint8Array(arrayBuffer);
|
|
57
|
+
if (data.length === 0 || data[0] !== 48) {
|
|
58
|
+
throw new SignaturePolicyError("Invalid DER format in policy document");
|
|
59
|
+
}
|
|
60
|
+
const asn1 = decodeDer(data);
|
|
61
|
+
const children = asn1.value;
|
|
62
|
+
if (!Array.isArray(children) || children.length < 3) {
|
|
63
|
+
throw new SignaturePolicyError(`Unexpected policy structure: expected 3+ children, got ${children?.length}`);
|
|
64
|
+
}
|
|
65
|
+
const algIdChildren = children[0].value;
|
|
66
|
+
if (!Array.isArray(algIdChildren) || algIdChildren.length === 0) {
|
|
67
|
+
throw new SignaturePolicyError("Invalid AlgorithmIdentifier in policy");
|
|
68
|
+
}
|
|
69
|
+
const { bytesToOid } = await import("@f-o-t/asn1");
|
|
70
|
+
const hashAlgOid = bytesToOid(algIdChildren[0].value);
|
|
71
|
+
const hashNode = children[2];
|
|
72
|
+
if (hashNode.tag !== 4) {
|
|
73
|
+
throw new SignaturePolicyError(`Expected OCTET STRING at child[2], got tag 0x${hashNode.tag.toString(16)}`);
|
|
74
|
+
}
|
|
75
|
+
cachedPolicyData = {
|
|
76
|
+
hashAlgOid,
|
|
77
|
+
policyHash: hashNode.value
|
|
78
|
+
};
|
|
79
|
+
return cachedPolicyData;
|
|
80
|
+
}
|
|
81
|
+
async function buildSignaturePolicy() {
|
|
82
|
+
const { hashAlgOid, policyHash } = await downloadAndParsePolicyDocument();
|
|
83
|
+
const hashAlgId = sequence(oid(hashAlgOid));
|
|
84
|
+
const sigPolicyHash = sequence(hashAlgId, octetString(policyHash));
|
|
85
|
+
const sigPolicyQualifiers = sequence(sequence(oid(SPQ_ETS_URI_OID), ia5String(POLICY_CONFIG.URL)));
|
|
86
|
+
const signaturePolicyId = sequence(oid(POLICY_CONFIG.OID), sigPolicyHash, sigPolicyQualifiers);
|
|
87
|
+
return encodeDer(signaturePolicyId);
|
|
88
|
+
}
|
|
89
|
+
var ICP_BRASIL_OIDS = {
|
|
90
|
+
signingCertificateV2: SIGNING_CERTIFICATE_V2_OID,
|
|
91
|
+
signaturePolicy: SIGNATURE_POLICY_OID
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
class SignaturePolicyError extends Error {
|
|
95
|
+
constructor(message) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "SignaturePolicyError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/schemas.ts
|
|
102
|
+
import { z } from "zod";
|
|
103
|
+
var signatureAppearanceSchema = z.object({
|
|
104
|
+
x: z.number(),
|
|
105
|
+
y: z.number(),
|
|
106
|
+
width: z.number().positive(),
|
|
107
|
+
height: z.number().positive(),
|
|
108
|
+
page: z.number().int().min(0).optional(),
|
|
109
|
+
showQrCode: z.boolean().optional(),
|
|
110
|
+
showCertInfo: z.boolean().optional()
|
|
111
|
+
});
|
|
112
|
+
var qrCodeConfigSchema = z.object({
|
|
113
|
+
data: z.string().optional(),
|
|
114
|
+
size: z.number().int().positive().optional()
|
|
115
|
+
});
|
|
116
|
+
var pdfSignOptionsSchema = z.object({
|
|
117
|
+
certificate: z.object({
|
|
118
|
+
p12: z.instanceof(Uint8Array).refine((v) => v.length > 0, {
|
|
119
|
+
message: "P12 data must not be empty"
|
|
120
|
+
}),
|
|
121
|
+
password: z.string(),
|
|
122
|
+
name: z.string().optional()
|
|
123
|
+
}),
|
|
124
|
+
reason: z.string().optional(),
|
|
125
|
+
location: z.string().optional(),
|
|
126
|
+
contactInfo: z.string().optional(),
|
|
127
|
+
policy: z.enum(["pades-ades", "pades-icp-brasil"]).optional(),
|
|
128
|
+
timestamp: z.boolean().optional(),
|
|
129
|
+
tsaUrl: z.string().url().optional(),
|
|
130
|
+
tsaTimeout: z.number().positive().optional(),
|
|
131
|
+
tsaRetries: z.number().int().min(0).optional(),
|
|
132
|
+
tsaFallbackUrls: z.array(z.string().url()).optional(),
|
|
133
|
+
onTimestampError: z.function({ input: z.tuple([z.unknown()]), output: z.void() }).optional(),
|
|
134
|
+
appearance: z.union([signatureAppearanceSchema, z.literal(false)]).optional(),
|
|
135
|
+
appearances: z.array(signatureAppearanceSchema).optional(),
|
|
136
|
+
qrCode: qrCodeConfigSchema.optional(),
|
|
137
|
+
docMdpPermission: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional()
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// src/timestamp.ts
|
|
141
|
+
import {
|
|
142
|
+
boolean as asn1Boolean,
|
|
143
|
+
decodeDer as decodeDer2,
|
|
144
|
+
encodeDer as encodeDer2,
|
|
145
|
+
integer,
|
|
146
|
+
octetString as octetString2,
|
|
147
|
+
oid as oid2,
|
|
148
|
+
sequence as sequence2
|
|
149
|
+
} from "@f-o-t/asn1";
|
|
150
|
+
import { hash as hash2 } from "@f-o-t/crypto";
|
|
151
|
+
var HASH_OIDS = {
|
|
152
|
+
sha256: "2.16.840.1.101.3.4.2.1",
|
|
153
|
+
sha384: "2.16.840.1.101.3.4.2.2",
|
|
154
|
+
sha512: "2.16.840.1.101.3.4.2.3"
|
|
155
|
+
};
|
|
156
|
+
var TIMESTAMP_SERVERS = {
|
|
157
|
+
VALID: "http://timestamp.valid.com.br/tsa",
|
|
158
|
+
SAFEWEB: "http://tsa.safeweb.com.br/tsa/tsa",
|
|
159
|
+
CERTISIGN: "http://timestamp.certisign.com.br"
|
|
160
|
+
};
|
|
161
|
+
var TIMESTAMP_TOKEN_OID = "1.2.840.113549.1.9.16.2.14";
|
|
162
|
+
async function requestTimestamp(dataToTimestamp, tsaUrl, hashAlgorithm = "sha256", options) {
|
|
163
|
+
const messageHash = hash2(hashAlgorithm, dataToTimestamp);
|
|
164
|
+
const timestampReq = buildTimestampRequest(messageHash, hashAlgorithm);
|
|
165
|
+
const tsaTimeout = options?.tsaTimeout ?? 1e4;
|
|
166
|
+
const tsaRetries = options?.tsaRetries ?? 0;
|
|
167
|
+
const tsaFallbackUrls = options?.tsaFallbackUrls ?? [];
|
|
168
|
+
let lastError;
|
|
169
|
+
for (let attempt = 1;attempt <= 1 + tsaRetries; attempt++) {
|
|
170
|
+
if (attempt > 1) {
|
|
171
|
+
await sleep(2 ** (attempt - 2) * 1000);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return await fetchTimestamp(tsaUrl, timestampReq, tsaTimeout);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const fallbackUrl of tsaFallbackUrls) {
|
|
180
|
+
try {
|
|
181
|
+
return await fetchTimestamp(fallbackUrl, timestampReq, tsaTimeout);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const fallbackList = tsaFallbackUrls.length > 0 ? `, fallbacks: [${tsaFallbackUrls.join(", ")}]` : "";
|
|
187
|
+
throw new TimestampError(`TSA request failed: all servers unreachable (primary: ${tsaUrl}${fallbackList}). Last error: ${lastError?.message ?? "unknown"}`);
|
|
188
|
+
}
|
|
189
|
+
function sleep(ms) {
|
|
190
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
191
|
+
}
|
|
192
|
+
async function fetchTimestamp(url, timestampReq, timeoutMs) {
|
|
193
|
+
let response;
|
|
194
|
+
try {
|
|
195
|
+
response = await fetch(url, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/timestamp-query"
|
|
199
|
+
},
|
|
200
|
+
body: timestampReq,
|
|
201
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
205
|
+
throw new Error(`TSA server unreachable: ${url} \u2014 ${msg}`);
|
|
206
|
+
}
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new TimestampError(`TSA returned HTTP ${response.status}`);
|
|
209
|
+
}
|
|
210
|
+
const respBuffer = new Uint8Array(await response.arrayBuffer());
|
|
211
|
+
return extractTimestampToken(respBuffer);
|
|
212
|
+
}
|
|
213
|
+
function buildTimestampRequest(messageHash, hashAlgorithm) {
|
|
214
|
+
const hashOid = HASH_OIDS[hashAlgorithm];
|
|
215
|
+
if (!hashOid) {
|
|
216
|
+
throw new TimestampError(`Unsupported hash algorithm: ${hashAlgorithm}`);
|
|
217
|
+
}
|
|
218
|
+
const timestampReq = sequence2(integer(1), sequence2(sequence2(oid2(hashOid)), octetString2(messageHash)), asn1Boolean(true));
|
|
219
|
+
return encodeDer2(timestampReq);
|
|
220
|
+
}
|
|
221
|
+
function extractTimestampToken(respDer) {
|
|
222
|
+
let resp;
|
|
223
|
+
try {
|
|
224
|
+
resp = decodeDer2(respDer);
|
|
225
|
+
} catch {
|
|
226
|
+
throw new TimestampError("Invalid timestamp response: not valid DER");
|
|
227
|
+
}
|
|
228
|
+
const children = resp.value;
|
|
229
|
+
if (!Array.isArray(children) || children.length < 1) {
|
|
230
|
+
throw new TimestampError("Invalid timestamp response: unexpected structure");
|
|
231
|
+
}
|
|
232
|
+
const statusInfo = children[0].value;
|
|
233
|
+
const statusBytes = statusInfo[0].value;
|
|
234
|
+
const statusValue = statusBytes[statusBytes.length - 1];
|
|
235
|
+
if (statusValue !== 0 && statusValue !== 1) {
|
|
236
|
+
throw new TimestampError(`Timestamp request rejected with status: ${statusValue}`);
|
|
237
|
+
}
|
|
238
|
+
if (!children[1]) {
|
|
239
|
+
throw new TimestampError("Timestamp response does not contain a token");
|
|
240
|
+
}
|
|
241
|
+
return encodeDer2(children[1]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
class TimestampError extends Error {
|
|
245
|
+
constructor(message) {
|
|
246
|
+
super(message);
|
|
247
|
+
this.name = "TimestampError";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/sign-pdf.ts
|
|
252
|
+
import { decodeDer as decodeDer3 } from "@f-o-t/asn1";
|
|
253
|
+
import {
|
|
254
|
+
appendUnauthAttributes,
|
|
255
|
+
createSignedData,
|
|
256
|
+
parsePkcs12
|
|
257
|
+
} from "@f-o-t/crypto";
|
|
258
|
+
import { parseCertificate } from "@f-o-t/digital-certificate";
|
|
259
|
+
import {
|
|
260
|
+
embedSignature,
|
|
261
|
+
extractBytesToSign,
|
|
262
|
+
findByteRange,
|
|
263
|
+
loadPdf
|
|
264
|
+
} from "@f-o-t/pdf/plugins/editing";
|
|
265
|
+
|
|
266
|
+
// src/appearance.ts
|
|
267
|
+
import { hash as hash3 } from "@f-o-t/crypto";
|
|
268
|
+
import { generateQrCode } from "@f-o-t/qrcode";
|
|
269
|
+
function drawSignatureAppearance(doc, page, appearance, certInfo, options) {
|
|
270
|
+
const { x, width, height } = appearance;
|
|
271
|
+
const showQrCode = appearance.showQrCode !== false;
|
|
272
|
+
const showCertInfo = appearance.showCertInfo !== false;
|
|
273
|
+
const y = page.height - appearance.y - height;
|
|
274
|
+
let qrSize = 0;
|
|
275
|
+
if (showQrCode) {
|
|
276
|
+
const qrImage = options.preEmbeddedQr ?? (() => {
|
|
277
|
+
const qrData = options.qrCode?.data ?? createVerificationData(certInfo, options.pdfData);
|
|
278
|
+
const qrPng = generateQrCode(qrData, {
|
|
279
|
+
size: options.qrCode?.size ?? 100
|
|
280
|
+
});
|
|
281
|
+
return doc.embedPng(qrPng);
|
|
282
|
+
})();
|
|
283
|
+
qrSize = Math.min(100, height - 20);
|
|
284
|
+
page.drawImage(qrImage, {
|
|
285
|
+
x: x + 10,
|
|
286
|
+
y: y + 10,
|
|
287
|
+
width: qrSize,
|
|
288
|
+
height: qrSize
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (showCertInfo) {
|
|
292
|
+
drawCertInfo(page, certInfo, {
|
|
293
|
+
x,
|
|
294
|
+
y,
|
|
295
|
+
width,
|
|
296
|
+
height,
|
|
297
|
+
qrOffset: qrSize > 0 ? qrSize + 20 : 10,
|
|
298
|
+
reason: options.reason,
|
|
299
|
+
location: options.location
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function drawCertInfo(page, certInfo, opts) {
|
|
304
|
+
const textX = opts.x + opts.qrOffset;
|
|
305
|
+
let textY = opts.y + opts.height - 20;
|
|
306
|
+
const fontSize = 10;
|
|
307
|
+
const lineHeight = 14;
|
|
308
|
+
page.drawText("ASSINADO DIGITALMENTE", {
|
|
309
|
+
x: textX,
|
|
310
|
+
y: textY,
|
|
311
|
+
size: 12
|
|
312
|
+
});
|
|
313
|
+
textY -= lineHeight * 1.5;
|
|
314
|
+
if (certInfo) {
|
|
315
|
+
const signerName = certInfo.subject.commonName || "N/A";
|
|
316
|
+
page.drawText(`Assinado por: ${signerName}`, {
|
|
317
|
+
x: textX,
|
|
318
|
+
y: textY,
|
|
319
|
+
size: fontSize
|
|
320
|
+
});
|
|
321
|
+
textY -= lineHeight;
|
|
322
|
+
if (certInfo.brazilian.cnpj) {
|
|
323
|
+
const cnpj = formatCnpj(certInfo.brazilian.cnpj);
|
|
324
|
+
page.drawText(`CNPJ: ${cnpj}`, {
|
|
325
|
+
x: textX,
|
|
326
|
+
y: textY,
|
|
327
|
+
size: fontSize
|
|
328
|
+
});
|
|
329
|
+
textY -= lineHeight;
|
|
330
|
+
} else if (certInfo.brazilian.cpf) {
|
|
331
|
+
const cpf = formatCpf(certInfo.brazilian.cpf);
|
|
332
|
+
page.drawText(`CPF: ${cpf}`, {
|
|
333
|
+
x: textX,
|
|
334
|
+
y: textY,
|
|
335
|
+
size: fontSize
|
|
336
|
+
});
|
|
337
|
+
textY -= lineHeight;
|
|
338
|
+
}
|
|
339
|
+
const now = new Date;
|
|
340
|
+
const dateStr = now.toLocaleDateString("pt-BR");
|
|
341
|
+
const timeStr = now.toLocaleTimeString("pt-BR");
|
|
342
|
+
page.drawText(`Data: ${dateStr} ${timeStr}`, {
|
|
343
|
+
x: textX,
|
|
344
|
+
y: textY,
|
|
345
|
+
size: fontSize
|
|
346
|
+
});
|
|
347
|
+
textY -= lineHeight;
|
|
348
|
+
if (opts.location) {
|
|
349
|
+
page.drawText(`Local: ${opts.location}`, {
|
|
350
|
+
x: textX,
|
|
351
|
+
y: textY,
|
|
352
|
+
size: fontSize - 1
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
page.drawText(`Signed: ${opts.reason || "Digital Signature"}`, {
|
|
357
|
+
x: textX,
|
|
358
|
+
y: textY,
|
|
359
|
+
size: fontSize
|
|
360
|
+
});
|
|
361
|
+
textY -= lineHeight;
|
|
362
|
+
if (opts.location) {
|
|
363
|
+
page.drawText(`Location: ${opts.location}`, {
|
|
364
|
+
x: textX,
|
|
365
|
+
y: textY,
|
|
366
|
+
size: fontSize
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function precomputeSharedQrImage(doc, certInfo, pdfData, qrConfig) {
|
|
372
|
+
const qrData = qrConfig?.data ?? createVerificationData(certInfo, pdfData);
|
|
373
|
+
const qrPng = generateQrCode(qrData, { size: qrConfig?.size ?? 100 });
|
|
374
|
+
return doc.embedPng(qrPng);
|
|
375
|
+
}
|
|
376
|
+
function createVerificationData(certInfo, pdfData) {
|
|
377
|
+
const documentHash = toHex(hash3("sha256", pdfData));
|
|
378
|
+
const timestamp = new Date().toISOString();
|
|
379
|
+
if (certInfo) {
|
|
380
|
+
const certFingerprint = certInfo.fingerprint;
|
|
381
|
+
return `https://validar.iti.gov.br/?` + `doc=${documentHash.substring(0, 16)}&` + `cert=${certFingerprint.substring(0, 16)}&` + `time=${encodeURIComponent(timestamp)}`;
|
|
382
|
+
}
|
|
383
|
+
return `https://validar.iti.gov.br/?doc=${documentHash.substring(0, 16)}&time=${encodeURIComponent(timestamp)}`;
|
|
384
|
+
}
|
|
385
|
+
function formatCnpj(cnpj) {
|
|
386
|
+
return cnpj.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, "$1.$2.$3/$4-$5");
|
|
387
|
+
}
|
|
388
|
+
function formatCpf(cpf) {
|
|
389
|
+
return cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4");
|
|
390
|
+
}
|
|
391
|
+
function toHex(data) {
|
|
392
|
+
const chars = [];
|
|
393
|
+
for (let i = 0;i < data.length; i++) {
|
|
394
|
+
chars.push(data[i].toString(16).padStart(2, "0"));
|
|
395
|
+
}
|
|
396
|
+
return chars.join("");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/sign-pdf.ts
|
|
400
|
+
async function signPdf(pdf, options) {
|
|
401
|
+
let pdfBytes;
|
|
402
|
+
if (pdf instanceof ReadableStream) {
|
|
403
|
+
const chunks = [];
|
|
404
|
+
const reader = pdf.getReader();
|
|
405
|
+
try {
|
|
406
|
+
while (true) {
|
|
407
|
+
const { done, value } = await reader.read();
|
|
408
|
+
if (done)
|
|
409
|
+
break;
|
|
410
|
+
if (value)
|
|
411
|
+
chunks.push(value);
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
reader.releaseLock();
|
|
415
|
+
}
|
|
416
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
417
|
+
pdfBytes = new Uint8Array(totalLength);
|
|
418
|
+
let offset = 0;
|
|
419
|
+
for (const chunk of chunks) {
|
|
420
|
+
pdfBytes.set(chunk, offset);
|
|
421
|
+
offset += chunk.length;
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
pdfBytes = pdf;
|
|
425
|
+
}
|
|
426
|
+
const opts = pdfSignOptionsSchema.parse(options);
|
|
427
|
+
const { certificate, privateKey, chain } = parsePkcs12(opts.certificate.p12, opts.certificate.password);
|
|
428
|
+
let certInfo = null;
|
|
429
|
+
try {
|
|
430
|
+
certInfo = parseCertificate(opts.certificate.p12, opts.certificate.password);
|
|
431
|
+
} catch {}
|
|
432
|
+
const doc = loadPdf(pdfBytes);
|
|
433
|
+
if (opts.appearance !== false && opts.appearance) {
|
|
434
|
+
const pageIndex = opts.appearance.page ?? 0;
|
|
435
|
+
if (pageIndex < 0 || pageIndex >= doc.pageCount) {
|
|
436
|
+
throw new PdfSignError(`Invalid page index: ${pageIndex}. PDF has ${doc.pageCount} pages.`);
|
|
437
|
+
}
|
|
438
|
+
const page = doc.getPage(pageIndex);
|
|
439
|
+
drawSignatureAppearance(doc, page, opts.appearance, certInfo, {
|
|
440
|
+
reason: opts.reason,
|
|
441
|
+
location: opts.location,
|
|
442
|
+
qrCode: opts.qrCode,
|
|
443
|
+
pdfData: pdfBytes
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (opts.appearances && opts.appearances.length > 0) {
|
|
447
|
+
const needsQr = opts.appearances.some((a) => a.showQrCode !== false);
|
|
448
|
+
const sharedQrImage = needsQr ? precomputeSharedQrImage(doc, certInfo, pdfBytes, opts.qrCode) : undefined;
|
|
449
|
+
for (const app of opts.appearances) {
|
|
450
|
+
const pageIndex = app.page ?? 0;
|
|
451
|
+
if (pageIndex < 0 || pageIndex >= doc.pageCount) {
|
|
452
|
+
throw new PdfSignError(`Invalid page index ${pageIndex} in appearances. PDF has ${doc.pageCount} pages.`);
|
|
453
|
+
}
|
|
454
|
+
const page = doc.getPage(pageIndex);
|
|
455
|
+
drawSignatureAppearance(doc, page, app, certInfo, {
|
|
456
|
+
reason: opts.reason,
|
|
457
|
+
location: opts.location,
|
|
458
|
+
qrCode: opts.qrCode,
|
|
459
|
+
pdfData: pdfBytes,
|
|
460
|
+
preEmbeddedQr: sharedQrImage
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const signerName = certInfo?.subject.commonName || opts.certificate.name || "Digital Signature";
|
|
465
|
+
const certChainBytes = certificate.length + chain.reduce((sum, c) => sum + c.length, 0);
|
|
466
|
+
const signatureLength = Math.max(16384, certChainBytes * 2 + (opts.tsaUrl ? 4096 : 0) + 8192);
|
|
467
|
+
const appearancePage = opts.appearance ? opts.appearance.page ?? 0 : opts.appearances?.[0]?.page ?? 0;
|
|
468
|
+
const { pdf: pdfWithPlaceholder } = doc.saveWithPlaceholder({
|
|
469
|
+
reason: opts.reason || "Digitally signed",
|
|
470
|
+
name: signerName,
|
|
471
|
+
location: opts.location,
|
|
472
|
+
contactInfo: opts.contactInfo,
|
|
473
|
+
signatureLength,
|
|
474
|
+
docMdpPermission: opts.docMdpPermission ?? 2,
|
|
475
|
+
appearancePage
|
|
476
|
+
});
|
|
477
|
+
const { byteRange } = findByteRange(pdfWithPlaceholder);
|
|
478
|
+
const bytesToSign = extractBytesToSign(pdfWithPlaceholder, byteRange);
|
|
479
|
+
const authenticatedAttributes = [];
|
|
480
|
+
if (opts.policy === "pades-icp-brasil") {
|
|
481
|
+
const sigCertV2 = buildSigningCertificateV2(certificate);
|
|
482
|
+
authenticatedAttributes.push({
|
|
483
|
+
oid: ICP_BRASIL_OIDS.signingCertificateV2,
|
|
484
|
+
values: [sigCertV2]
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
const sigPolicy = await buildSignaturePolicy();
|
|
488
|
+
authenticatedAttributes.push({
|
|
489
|
+
oid: ICP_BRASIL_OIDS.signaturePolicy,
|
|
490
|
+
values: [sigPolicy]
|
|
491
|
+
});
|
|
492
|
+
} catch {}
|
|
493
|
+
}
|
|
494
|
+
const unauthenticatedAttributes = [];
|
|
495
|
+
const signedData = createSignedData({
|
|
496
|
+
content: bytesToSign,
|
|
497
|
+
certificate,
|
|
498
|
+
privateKey,
|
|
499
|
+
chain,
|
|
500
|
+
hashAlgorithm: "sha256",
|
|
501
|
+
authenticatedAttributes: authenticatedAttributes.length > 0 ? authenticatedAttributes : undefined,
|
|
502
|
+
unauthenticatedAttributes: unauthenticatedAttributes.length > 0 ? unauthenticatedAttributes : undefined,
|
|
503
|
+
detached: true
|
|
504
|
+
});
|
|
505
|
+
if (opts.timestamp && opts.tsaUrl) {
|
|
506
|
+
try {
|
|
507
|
+
const tsToken = await requestTimestamp(extractSignatureValue(signedData), opts.tsaUrl, "sha256", {
|
|
508
|
+
tsaTimeout: opts.tsaTimeout,
|
|
509
|
+
tsaRetries: opts.tsaRetries,
|
|
510
|
+
tsaFallbackUrls: opts.tsaFallbackUrls
|
|
511
|
+
});
|
|
512
|
+
unauthenticatedAttributes.push({
|
|
513
|
+
oid: TIMESTAMP_TOKEN_OID,
|
|
514
|
+
values: [tsToken]
|
|
515
|
+
});
|
|
516
|
+
} catch (err) {
|
|
517
|
+
opts.onTimestampError?.(err);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const finalSignedData = appendUnauthAttributes(signedData, unauthenticatedAttributes);
|
|
521
|
+
return embedSignature(pdfWithPlaceholder, finalSignedData);
|
|
522
|
+
}
|
|
523
|
+
function extractSignatureValue(contentInfoDer) {
|
|
524
|
+
const contentInfo = decodeDer3(contentInfoDer);
|
|
525
|
+
const signedDataNode = contentInfo.value[1].value[0];
|
|
526
|
+
const signerInfosSet = signedDataNode.value.at(-1);
|
|
527
|
+
const signerInfo = signerInfosSet.value[0];
|
|
528
|
+
const signatureNode = signerInfo.value[5];
|
|
529
|
+
return signatureNode.value;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
class PdfSignError extends Error {
|
|
533
|
+
constructor(message) {
|
|
534
|
+
super(message);
|
|
535
|
+
this.name = "PdfSignError";
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export { clearPolicyCache, buildSigningCertificateV2, buildSignaturePolicy, ICP_BRASIL_OIDS, SignaturePolicyError, pdfSignOptionsSchema, TIMESTAMP_SERVERS, TIMESTAMP_TOKEN_OID, requestTimestamp, TimestampError, signPdf, PdfSignError };
|
|
540
|
+
|
|
541
|
+
//# debugId=7AE89559679974C564756E2164756E21
|