@e-sig/core 0.4.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/CONSUMING.md +97 -0
- package/LICENSE +21 -0
- package/README.md +298 -0
- package/dist/adapters.d.ts +79 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +10 -0
- package/dist/adapters.js.map +1 -0
- package/dist/cert-issuer.d.ts +47 -0
- package/dist/cert-issuer.d.ts.map +1 -0
- package/dist/cert-issuer.js +132 -0
- package/dist/cert-issuer.js.map +1 -0
- package/dist/cert-lifecycle.d.ts +16 -0
- package/dist/cert-lifecycle.d.ts.map +1 -0
- package/dist/cert-lifecycle.js +30 -0
- package/dist/cert-lifecycle.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/pem-signer.d.ts +42 -0
- package/dist/pem-signer.d.ts.map +1 -0
- package/dist/pem-signer.js +276 -0
- package/dist/pem-signer.js.map +1 -0
- package/dist/render-pdf.d.ts +33 -0
- package/dist/render-pdf.d.ts.map +1 -0
- package/dist/render-pdf.js +86 -0
- package/dist/render-pdf.js.map +1 -0
- package/dist/sign-document.d.ts +54 -0
- package/dist/sign-document.d.ts.map +1 -0
- package/dist/sign-document.js +93 -0
- package/dist/sign-document.js.map +1 -0
- package/dist/sign-pdf.d.ts +46 -0
- package/dist/sign-pdf.d.ts.map +1 -0
- package/dist/sign-pdf.js +60 -0
- package/dist/sign-pdf.js.map +1 -0
- package/dist/signature-block.d.ts +23 -0
- package/dist/signature-block.d.ts.map +1 -0
- package/dist/signature-block.js +63 -0
- package/dist/signature-block.js.map +1 -0
- package/dist/timestamp.d.ts +59 -0
- package/dist/timestamp.d.ts.map +1 -0
- package/dist/timestamp.js +278 -0
- package/dist/timestamp.js.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/dist/verify-pdf.d.ts +33 -0
- package/dist/verify-pdf.d.ts.map +1 -0
- package/dist/verify-pdf.js +338 -0
- package/dist/verify-pdf.js.map +1 -0
- package/package.json +92 -0
package/CONSUMING.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Consuming `@e-sig/core`
|
|
2
|
+
|
|
3
|
+
How to install and use this package in another project. It is a self-contained,
|
|
4
|
+
dependency-light PKCS#7 PDF signing core — no SaaS, no metering, no per-document
|
|
5
|
+
fees — extracted from the Opendelphi production e-signature pipeline.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Published to the public npm registry — no auth or registry config needed:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @e-sig/core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Runtime requirements: Node ≥ 20, ESM. The only crypto dependencies are
|
|
16
|
+
`node-forge` and the `@signpdf/*` packages (declared as dependencies).
|
|
17
|
+
`@sparticuz/chromium` is an optional peer (only needed for `renderHtmlToPdf` on
|
|
18
|
+
Lambda/Vercel).
|
|
19
|
+
|
|
20
|
+
## Minimal sign + verify
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { generateSelfSignedCert, signPdf, verifyPdfStructure } from "@e-sig/core";
|
|
24
|
+
|
|
25
|
+
const { keyPem, certPem } = generateSelfSignedCert({ subjectName: "Acme" });
|
|
26
|
+
const { signedPdf } = await signPdf({
|
|
27
|
+
pdf, keyPem, certPem,
|
|
28
|
+
reason: "Agreement", location: "example.org",
|
|
29
|
+
contactInfo: "legal@acme.org", name: "Acme",
|
|
30
|
+
});
|
|
31
|
+
const v = verifyPdfStructure(signedPdf); // { ok, signerCommonName, ... }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## RFC 3161 trusted timestamps (CAdES-T)
|
|
35
|
+
|
|
36
|
+
Pass a `tsa` transport to upgrade the signature from CAdES-B to CAdES-T by
|
|
37
|
+
embedding an RFC 3161 TimeStampToken (`id-aa-timeStampToken`,
|
|
38
|
+
OID `1.2.840.113549.1.9.16.2.14`) over the SignerInfo signatureValue.
|
|
39
|
+
|
|
40
|
+
**The consumer injects the network POST.** The package never performs egress
|
|
41
|
+
itself, which keeps it dependency-free and keeps egress under your control. The
|
|
42
|
+
TSA only ever receives a **SHA-256 hash** — never the document, never any PHI.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import type { TsaTransport } from "@e-sig/core";
|
|
46
|
+
|
|
47
|
+
const tsa: TsaTransport = {
|
|
48
|
+
required: false, // false → degrade to CAdES-B on failure; true → throw
|
|
49
|
+
fetch: async (reqDerBytes) => {
|
|
50
|
+
const res = await fetch("http://timestamp.digicert.com", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/timestamp-query" },
|
|
53
|
+
body: reqDerBytes,
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) throw new Error(`TSA HTTP ${res.status}`);
|
|
56
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const { signedPdf, timestamped, tsaError } = await signPdf({
|
|
61
|
+
pdf, keyPem, certPem,
|
|
62
|
+
reason: "Agreement", location: "example.org",
|
|
63
|
+
contactInfo: "legal@acme.org", name: "Acme",
|
|
64
|
+
tsa,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const v = verifyPdfStructure(signedPdf);
|
|
68
|
+
// v.timestamped, v.timestampTime (ISO 8601), v.tsaCommonName
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### What you need to know
|
|
72
|
+
|
|
73
|
+
- **Placeholder budget.** When `tsa` is provided and you do not set
|
|
74
|
+
`signatureLength`, the `/Contents` budget defaults to **30720** bytes (vs
|
|
75
|
+
`8192` without a TSA) to fit the TimeStampToken plus the TSA certificate
|
|
76
|
+
chain. If the signed PKCS#7 would overflow the placeholder, `signPdf` throws
|
|
77
|
+
rather than producing a silently-truncated signature. Override
|
|
78
|
+
`signatureLength` only if your TSA's chain is unusually large.
|
|
79
|
+
- **Privacy.** The request sent to the TSA is an RFC 3161 `TimeStampReq` whose
|
|
80
|
+
`messageImprint` is `sha256(SignerInfo.signature)`. No document bytes, no
|
|
81
|
+
identifiers, no PHI leave your process beyond that single hash.
|
|
82
|
+
- **Failure modes.** With `required: false` (default) a TSA error yields a valid
|
|
83
|
+
CAdES-B signature and populates `tsaError`; `timestamped` is `false`. With
|
|
84
|
+
`required: true` the error is rethrown and no signature is returned.
|
|
85
|
+
- **Verification.** `verifyPdfStructure` enforces the RFC 3161 §2.4.2 binding:
|
|
86
|
+
the token's `messageImprint` must equal `sha256(SignerInfo.signature)`. A
|
|
87
|
+
mismatch sets `ok:false` with `"timestamp messageImprint does not match
|
|
88
|
+
signature value"`. A PDF with no timestamp verifies normally with
|
|
89
|
+
`timestamped:false` (backward compatible).
|
|
90
|
+
|
|
91
|
+
### Choosing a TSA
|
|
92
|
+
|
|
93
|
+
Any RFC 3161 TSA works. Free public TSAs include
|
|
94
|
+
`http://timestamp.digicert.com` and `http://timestamp.sectigo.com`. For
|
|
95
|
+
HIPAA/regulated contexts, prefer a TSA you have a relationship with; remember
|
|
96
|
+
the TSA only sees a hash, so a BAA is generally not required for the hash
|
|
97
|
+
itself — confirm with your compliance owner.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Opendelphi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# `@e-sig/core` — Portable In-Platform E-Signature
|
|
2
|
+
|
|
3
|
+
> Self-contained PKCS#7 PDF signing — no SaaS, no metering, no per-doc fees.
|
|
4
|
+
> Battle-tested in production at [opendelphi.org](https://opendelphi.org).
|
|
5
|
+
|
|
6
|
+
This directory is the **portable core** of the Opendelphi e-signature pipeline. Drop it (plus a tiny adapter you write) into any TypeScript / Node.js project to add real cryptographic signing of PDFs.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## What it does
|
|
11
|
+
|
|
12
|
+
Given an HTML document and a person who wants to sign it, this library:
|
|
13
|
+
|
|
14
|
+
1. Renders the HTML to a PDF (headless Chromium via `puppeteer-core`; scripting disabled by default).
|
|
15
|
+
2. Generates or reuses a self-signed RSA-2048 X.509 cert for the signing tenant.
|
|
16
|
+
3. Embeds a PKCS#7 detached signature under the **ETSI.CAdES.detached** subfilter, with the ESS **signing-certificate-v2** attribute binding the signer cert into the signed data. Pass `padesStrict: true` for strict **PAdES B-B** (also drops the PAdES-forbidden `signing-time` attribute).
|
|
17
|
+
4. Produces a PDF that opens cleanly in Preview / Adobe Reader with a valid signature panel — any post-signing edit invalidates the signature. `verifyPdfSignature()` checks this cryptographically (recomputes the digest over the signed ByteRange and RSA-verifies the signature).
|
|
18
|
+
|
|
19
|
+
> **Trust vs. validity.** The signature is cryptographically *valid*, but the cert is *self-issued* — stock Adobe Reader shows "validity unknown" until the cert is trusted (org trust-store import, or plug in an AATL/CA signer). This verifies the signature math and integrity, not third-party trust. See the compliance notes below.
|
|
20
|
+
|
|
21
|
+
That's the **whole thing**. It's ~600 lines of TypeScript with zero runtime dependencies on Supabase, Next.js, or any SaaS.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## What it does NOT do
|
|
26
|
+
|
|
27
|
+
By design — these are wrapper concerns:
|
|
28
|
+
|
|
29
|
+
- **Storage of the signed PDF.** You decide where (S3, local disk, Supabase Storage, …).
|
|
30
|
+
- **Auth / authorization.** You decide who's allowed to sign.
|
|
31
|
+
- **UI.** You build the signature-capture surface.
|
|
32
|
+
- **Persistence of the cert pool.** Implement the `CertStore` adapter interface.
|
|
33
|
+
- **Audit logging.** Implement the `AuditLogStore` adapter interface.
|
|
34
|
+
|
|
35
|
+
The library gives you **crypto + rendering**. You bring **persistence + UI + auth**.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Files
|
|
40
|
+
|
|
41
|
+
| File | Purpose | Project-agnostic? |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `pem-signer.ts` | Custom `@signpdf` `Signer` driven by raw PEM key+cert (bypasses node-forge's broken P12 round-trip — see Background below) | ✅ |
|
|
44
|
+
| `cert-issuer.ts` | Generate self-signed RSA-2048 X.509; AES-256-GCM-wrap private keys for at-rest storage | ✅ |
|
|
45
|
+
| `render-pdf.ts` | HTML → PDF via puppeteer-core; auto-detects Lambda vs local Chrome | ✅ |
|
|
46
|
+
| `sign-pdf.ts` | Combine placeholder injection + PKCS#7 sign (ETSI.CAdES) | ✅ |
|
|
47
|
+
| `verify-pdf.ts` | Structural verifier (parses /ByteRange + PKCS#7 blob, returns diagnostics) | ✅ |
|
|
48
|
+
| `signature-block.ts` | HTML helper to render N signature blocks for multi-party flows | ✅ |
|
|
49
|
+
| `types.ts` | Shared TS types (`Signer`, `SigningCertPem`, …) | ✅ |
|
|
50
|
+
| `index.ts` | Public re-export barrel | ✅ |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install \
|
|
58
|
+
@signpdf/signpdf \
|
|
59
|
+
@signpdf/utils \
|
|
60
|
+
@signpdf/placeholder-plain \
|
|
61
|
+
node-forge \
|
|
62
|
+
puppeteer-core \
|
|
63
|
+
@sparticuz/chromium # only on Lambda; skip for local-only
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Plus `@types/node-forge` if you're using TypeScript.
|
|
67
|
+
|
|
68
|
+
If you're on Next.js, you MUST externalize the binary-adjacent packages in `next.config.ts`:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const nextConfig = {
|
|
72
|
+
serverExternalPackages: [
|
|
73
|
+
"@sparticuz/chromium",
|
|
74
|
+
"puppeteer-core",
|
|
75
|
+
"node-forge",
|
|
76
|
+
"@signpdf/signpdf",
|
|
77
|
+
"@signpdf/utils",
|
|
78
|
+
"@signpdf/placeholder-plain",
|
|
79
|
+
],
|
|
80
|
+
// The chromium binary tarball is a static asset; tell file-tracing to
|
|
81
|
+
// include it in your e-sig route's bundle.
|
|
82
|
+
outputFileTracingIncludes: {
|
|
83
|
+
"/api/your-esig-route": [
|
|
84
|
+
"./node_modules/@sparticuz/chromium/bin/**",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 30-second example
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import {
|
|
96
|
+
generateSelfSignedCert,
|
|
97
|
+
renderHtmlToPdf,
|
|
98
|
+
signPdf,
|
|
99
|
+
verifyPdfStructure,
|
|
100
|
+
} from "@e-sig/core";
|
|
101
|
+
|
|
102
|
+
// 1. Issue a one-off cert (in real life, persist + reuse).
|
|
103
|
+
const cert = generateSelfSignedCert({ subjectName: "Acme Corp" });
|
|
104
|
+
|
|
105
|
+
// 2. Render HTML → unsigned PDF.
|
|
106
|
+
const unsigned = await renderHtmlToPdf({
|
|
107
|
+
html: `<h1>Service Agreement</h1><p>Signed by Jane Doe at ${new Date().toISOString()}.</p>`,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 3. Sign it.
|
|
111
|
+
const { signedPdf } = await signPdf({
|
|
112
|
+
pdf: unsigned,
|
|
113
|
+
keyPem: cert.keyPem,
|
|
114
|
+
certPem: cert.certPem,
|
|
115
|
+
reason: "Service Agreement acceptance",
|
|
116
|
+
location: "https://acme.example",
|
|
117
|
+
contactInfo: "jane@example.com",
|
|
118
|
+
name: "Jane Doe",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// 4. Verify the result cryptographically (also exported as verifyPdfSignature).
|
|
122
|
+
// ok === true only when structure + document digest + RSA signature all pass;
|
|
123
|
+
// a single flipped byte under the signature makes ok=false / digestValid=false.
|
|
124
|
+
const verify = verifyPdfStructure(signedPdf);
|
|
125
|
+
console.log(verify.ok, verify.digestValid, verify.signatureValid, verify.signerCommonName);
|
|
126
|
+
// → true, true, true, "E-sig (Acme Corp)"
|
|
127
|
+
|
|
128
|
+
// 5. Persist + serve. Up to you.
|
|
129
|
+
require("fs").writeFileSync("./signed.pdf", signedPdf);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
That's it. Open `signed.pdf` in Preview — signature panel shows valid (self-signed).
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## RFC 3161 trusted timestamps (CAdES-T)
|
|
137
|
+
|
|
138
|
+
Pass a `tsa` transport to `signPdf` to embed an RFC 3161 TimeStampToken,
|
|
139
|
+
upgrading the signature from CAdES-B to CAdES-T. The token is added as the
|
|
140
|
+
`id-aa-timeStampToken` unsigned attribute (OID `1.2.840.113549.1.9.16.2.14`)
|
|
141
|
+
computed over the SignerInfo signatureValue (RFC 3161 §2.4.1).
|
|
142
|
+
|
|
143
|
+
The package performs **no network egress** — you inject the POST so the package
|
|
144
|
+
stays dependency-free. The TSA only ever receives a **SHA-256 hash**, never the
|
|
145
|
+
document or any PHI:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import type { TsaTransport } from "@e-sig/core";
|
|
149
|
+
|
|
150
|
+
const tsa: TsaTransport = {
|
|
151
|
+
required: false, // false = degrade to CAdES-B on TSA failure; true = throw
|
|
152
|
+
fetch: async (reqDerBytes) => {
|
|
153
|
+
const res = await fetch("http://timestamp.digicert.com", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/timestamp-query" },
|
|
156
|
+
body: reqDerBytes,
|
|
157
|
+
});
|
|
158
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const { signedPdf, timestamped, tsaError } = await signPdf({
|
|
163
|
+
pdf, keyPem, certPem,
|
|
164
|
+
reason: "DUA acceptance", location: "opendelphi.org",
|
|
165
|
+
contactInfo: "legal@acme.org", name: "Acme Research Institute",
|
|
166
|
+
tsa,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const v = verifyPdfStructure(signedPdf);
|
|
170
|
+
// v.timestamped, v.timestampTime (ISO), v.tsaCommonName
|
|
171
|
+
// v.ok is false if the §2.4.2 binding check fails (imprint != sha256(sigValue))
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Notes:
|
|
175
|
+
|
|
176
|
+
- **Budget**: when `tsa` is supplied and `signatureLength` is omitted, the
|
|
177
|
+
`/Contents` placeholder budget defaults to **30720** (vs `8192` without a TSA)
|
|
178
|
+
to fit the TimeStampToken plus the TSA certificate chain. An overflow is
|
|
179
|
+
rejected, never silently truncated.
|
|
180
|
+
- **Degradation**: with `required: false` (default), a TSA error produces a
|
|
181
|
+
valid CAdES-B signature and sets `tsaError`; with `required: true` the error
|
|
182
|
+
is rethrown.
|
|
183
|
+
- **Verification** enforces the RFC 3161 §2.4.2 binding: the token's
|
|
184
|
+
`messageImprint` must equal `sha256(SignerInfo.signature)`, else `ok:false`.
|
|
185
|
+
|
|
186
|
+
See `CONSUMING.md` for the full consumer guide.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Persisting certs + audit logs across requests
|
|
191
|
+
|
|
192
|
+
For real usage you need to:
|
|
193
|
+
- **Cache certs per tenant** so you don't regenerate on every sign.
|
|
194
|
+
- **Encrypt private keys at rest** so a DB leak doesn't compromise signing authority.
|
|
195
|
+
- **Log every sign** for ESIGN / UETA / 21 CFR §11 compliance evidence.
|
|
196
|
+
|
|
197
|
+
The library provides adapter **interfaces** (`CertStore`, `AuditLogStore` — see `../adapters/types.ts`). Implement them against your DB.
|
|
198
|
+
|
|
199
|
+
A reference Supabase implementation lives at `../adapters/supabase.ts` (~150 lines). It works against any schema with these two tables — copy the migration from `supabase/migrations/00106_esig_self_contained.sql` for the canonical shape, or write your own.
|
|
200
|
+
|
|
201
|
+
### CertStore interface
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
interface CertStore {
|
|
205
|
+
findActive(tenantId: string): Promise<StoredCert | null>;
|
|
206
|
+
insert(input: { tenantId; generated; keyPemEncrypted; rotatedFromId? }): Promise<StoredCert>;
|
|
207
|
+
deactivate(id: string): Promise<void>;
|
|
208
|
+
findExpiring(withinDays: number): Promise<StoredCert[]>;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### AuditLogStore interface
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
interface AuditLogStore {
|
|
216
|
+
insert(entry: AuditLogEntry): Promise<AuditLogRow>;
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Then use the convenience helper `ensureActiveCert` from `../adapters/supabase.ts` as a template:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
const result = await ensureActiveCert({
|
|
224
|
+
store: new YourCertStore(...),
|
|
225
|
+
tenantId: "acme-corp",
|
|
226
|
+
subjectName: "Acme Corp",
|
|
227
|
+
passphrase: process.env.ESIG_CERT_PASSPHRASE!,
|
|
228
|
+
});
|
|
229
|
+
// result.certPem + result.keyPem ready to feed into signPdf()
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Compliance posture
|
|
235
|
+
|
|
236
|
+
The Opendelphi production wire-up uses this library for HIPAA-bound Data Use Agreements and is mapped against:
|
|
237
|
+
|
|
238
|
+
- **ESIGN Act § 7001 (R1–R5)** — Intent, Consent to electronic, Attribution, Integrity, Retention. R4 Integrity is fully covered by the crypto core; R1/R2/R3/R5 are wrapper concerns.
|
|
239
|
+
- **UETA § 9 + § 13** — Attribution + system attribution log.
|
|
240
|
+
- **21 CFR § 11.50 / § 11.70** — FDA-grade requirements where applicable.
|
|
241
|
+
|
|
242
|
+
See `.planning/phases/19-esig-primitives-spike/19-04-ESIGN-GAPS.md` for the full mapping.
|
|
243
|
+
|
|
244
|
+
**Not legal advice.** Talk to your lawyer about whether this satisfies the regulatory framework for your specific use case.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Background — why this exists
|
|
249
|
+
|
|
250
|
+
### Why not DocuSign / DocuSeal / Documenso?
|
|
251
|
+
|
|
252
|
+
Per-document metering (~$0.20/sig) made the unit economics painful at scale. And every signed PDF flowed through a third-party processor — making HIPAA + GDPR compliance harder than it had to be.
|
|
253
|
+
|
|
254
|
+
This library is what you reach for when "no SaaS, no metering, no fees" is a hard requirement.
|
|
255
|
+
|
|
256
|
+
### Why not `pdf-lib`?
|
|
257
|
+
|
|
258
|
+
[`pdf-lib`](https://github.com/Hopding/pdf-lib) is the most popular Node PDF library, but it hasn't shipped a release since 2021. Documenso uses [`@libpdf/core`](https://github.com/libpdf/libpdf) instead — same conclusion here. (Neither is actually used by this core — we drive `puppeteer` for rendering and `@signpdf` + `node-forge` for signing, both actively maintained.)
|
|
259
|
+
|
|
260
|
+
### Why not PKCS#12?
|
|
261
|
+
|
|
262
|
+
We tried. `node-forge.pkcs12.toPkcs12Asn1` produces P12 bundles whose MAC neither node-forge **nor openssl** can verify. Looks like a long-standing BMPString-password-derivation bug. We bypass it entirely — the PemSigner takes raw PEM and drives `forge.pkcs7` directly.
|
|
263
|
+
|
|
264
|
+
### Bugs to avoid (we hit these so you don't have to)
|
|
265
|
+
|
|
266
|
+
1. **Don't use `node-forge.pkcs12.toPkcs12Asn1`** — see above.
|
|
267
|
+
2. **ASCII-only cert subject names** — `forge.pki.certificateFromPem` mis-counts bytes for non-ASCII (em-dash in OU breaks PEM round-trip with "Too few bytes to parse DER").
|
|
268
|
+
3. **`@signpdf/signpdf` v3 ESM default import is opaque** — use the named export: `import { SignPdf } from "@signpdf/signpdf"` and `new SignPdf().sign(...)`.
|
|
269
|
+
4. **`@sparticuz/chromium` is Lambda-only** — locally, use system Chrome via the `executablePath` override.
|
|
270
|
+
5. **Storing key + cert in one PEM file is fragile** — `forge.pem.decode` extracts blocks correctly, but re-encoding the cert block from a multi-block buffer doesn't survive `certificateFromPem` round-trip. Store them as separate files / DB columns.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Performance
|
|
275
|
+
|
|
276
|
+
End-to-end on Vercel Lambda (cold start), tested against [opendelphi.org](https://opendelphi.org/api/esig/dua-self-sign) in production:
|
|
277
|
+
|
|
278
|
+
- Render HTML → unsigned PDF: ~2.5 s (cold) / ~0.5 s (warm)
|
|
279
|
+
- Generate cert (first sign per tenant): ~0.8 s (RSA-2048 keygen dominates)
|
|
280
|
+
- PKCS#7 sign: ~0.1 s
|
|
281
|
+
- Upload + audit + DB row flip: ~0.3 s
|
|
282
|
+
- **Total cold-start round-trip: ~4.5 s**
|
|
283
|
+
|
|
284
|
+
Subsequent signs reuse the cached cert → ~1–1.5 s warm.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
Same as the parent project. The `core/` directory is intentionally self-contained so it can be vendored under your own license.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Acknowledgments
|
|
295
|
+
|
|
296
|
+
- **[@signpdf](https://github.com/vbuch/node-signpdf)** for the PKCS#7 placeholder + signing infrastructure.
|
|
297
|
+
- **[node-forge](https://github.com/digitalbazaar/forge)** for the X.509 + PKCS#7 + crypto primitives.
|
|
298
|
+
- **[Documenso](https://github.com/documenso/documenso)** + **[DocuSeal CE](https://github.com/docusealco/docuseal)** as reference implementations (read-only — no code copied — see `.planning/phases/19-esig-primitives-spike/19-02-PATTERNS-OBSERVED.md` for the lessons borrowed).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { GeneratedCert } from "./cert-issuer.js";
|
|
2
|
+
export interface StoredCert {
|
|
3
|
+
id: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
certPem: string;
|
|
6
|
+
/** Encrypted PEM key (whatever your at-rest encryption produces). */
|
|
7
|
+
keyPemEncrypted: Uint8Array;
|
|
8
|
+
certFingerprint: string;
|
|
9
|
+
notBefore: Date;
|
|
10
|
+
notAfter: Date;
|
|
11
|
+
active: boolean;
|
|
12
|
+
/** Optional: id of the cert this one replaced on rotation. */
|
|
13
|
+
rotatedFromId?: string | null;
|
|
14
|
+
createdAt: Date;
|
|
15
|
+
}
|
|
16
|
+
export interface CertStore {
|
|
17
|
+
/**
|
|
18
|
+
* Find the active (non-expired, not rotated) cert for a tenant.
|
|
19
|
+
* Return null if no active cert exists.
|
|
20
|
+
*/
|
|
21
|
+
findActive(tenantId: string): Promise<StoredCert | null>;
|
|
22
|
+
/**
|
|
23
|
+
* Insert a new cert. Implementations should ensure only one cert per tenant
|
|
24
|
+
* is `active=true` at any time (handle the "deactivate old + insert new"
|
|
25
|
+
* transaction yourself or in a single SQL statement).
|
|
26
|
+
*/
|
|
27
|
+
insert(input: {
|
|
28
|
+
tenantId: string;
|
|
29
|
+
generated: GeneratedCert;
|
|
30
|
+
keyPemEncrypted: Uint8Array;
|
|
31
|
+
rotatedFromId?: string | null;
|
|
32
|
+
}): Promise<StoredCert>;
|
|
33
|
+
/** Mark a cert as inactive (used during rotation). */
|
|
34
|
+
deactivate(id: string): Promise<void>;
|
|
35
|
+
/** Find all active certs whose notAfter is within `withinDays` of now. Used by a rotation cron. */
|
|
36
|
+
findExpiring(withinDays: number): Promise<StoredCert[]>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Standard action vocabulary. Implementations should allow these at minimum
|
|
40
|
+
* but may extend the set (caller passes the action string through).
|
|
41
|
+
*/
|
|
42
|
+
export type EsigAuditAction = "cert.created" | "cert.rotated" | "cert.deactivated" | "pdf.rendered" | "pdf.signed" | "pdf.verified" | "consent.recorded";
|
|
43
|
+
export interface AuditLogEntry {
|
|
44
|
+
tenantId: string;
|
|
45
|
+
action: EsigAuditAction | string;
|
|
46
|
+
actorUserId?: string | null;
|
|
47
|
+
targetTable?: string;
|
|
48
|
+
targetId?: string;
|
|
49
|
+
certId?: string;
|
|
50
|
+
certFingerprint?: string;
|
|
51
|
+
ip?: string;
|
|
52
|
+
userAgent?: string;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
signedPdfUrl?: string;
|
|
55
|
+
metadata?: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
export interface AuditLogRow {
|
|
58
|
+
id: string;
|
|
59
|
+
createdAt: Date;
|
|
60
|
+
}
|
|
61
|
+
export interface AuditLogStore {
|
|
62
|
+
/** Append one row. Returns the new row id + timestamp for FK references. */
|
|
63
|
+
insert(entry: AuditLogEntry): Promise<AuditLogRow>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Persists signed PDFs (and optionally signature images). The `signDocument()`
|
|
67
|
+
* orchestrator uses this to store the signed bytes and return a URL/path key.
|
|
68
|
+
*/
|
|
69
|
+
export interface PdfStorageStore {
|
|
70
|
+
/** Upload bytes at the given path. Returns the canonical URL or path key. */
|
|
71
|
+
upload(input: {
|
|
72
|
+
path: string;
|
|
73
|
+
bytes: Uint8Array;
|
|
74
|
+
contentType: string;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
url: string;
|
|
77
|
+
}>;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=adapters.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapters.d.ts","sourceRoot":"","sources":["../src/adapters.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAItD,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,eAAe,EAAE,UAAU,CAAC;IAC5B,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAEzD;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,aAAa,CAAC;QACzB,eAAe,EAAE,UAAU,CAAC;QAC5B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAC/B,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAExB,sDAAsD;IACtD,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEtC,mGAAmG;IACnG,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;CACzD;AAID;;;GAGG;AACH,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,cAAc,GACd,kBAAkB,GAClB,cAAc,GACd,YAAY,GACZ,cAAc,GACd,kBAAkB,CAAC;AAEvB,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,eAAe,GAAG,MAAM,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CACpD;AAID;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,MAAM,CAAC,KAAK,EAAE;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,UAAU,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC9B"}
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// adapters.ts
|
|
2
|
+
//
|
|
3
|
+
// Pluggable persistence interfaces. Implement these against your project's
|
|
4
|
+
// DB/storage to plug the portable signing engine into any stack. Two stores are
|
|
5
|
+
// required (CertStore + AuditLogStore); PdfStorageStore is optional but lets the
|
|
6
|
+
// `signDocument()` orchestrator persist the signed PDF for you.
|
|
7
|
+
//
|
|
8
|
+
// A Supabase reference implementation ships as `@e-sig/supabase`.
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=adapters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapters.js","sourceRoot":"","sources":["../src/adapters.ts"],"names":[],"mappings":"AAAA,cAAc;AACd,EAAE;AACF,2EAA2E;AAC3E,gFAAgF;AAChF,iFAAiF;AACjF,gEAAgE;AAChE,EAAE;AACF,kEAAkE"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import forge from "node-forge";
|
|
2
|
+
export interface GenerateCertOptions {
|
|
3
|
+
/** Subject CN seed — embedded in the cert's commonName + organizationName. ASCII-only. */
|
|
4
|
+
subjectName: string;
|
|
5
|
+
/** Validity period in days. Default 365. */
|
|
6
|
+
validityDays?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Override the OID extensions — defaults to a digital-signature-capable
|
|
9
|
+
* end-entity cert suitable for PKCS#7 detached PDF signing.
|
|
10
|
+
*/
|
|
11
|
+
extensions?: Parameters<ReturnType<typeof forge.pki.createCertificate>["setExtensions"]>[0];
|
|
12
|
+
/** Override the prefix added to commonName. Default: "E-sig". */
|
|
13
|
+
commonNamePrefix?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface GeneratedCert {
|
|
16
|
+
keyPem: string;
|
|
17
|
+
certPem: string;
|
|
18
|
+
/** SHA-256 hex of the DER-encoded cert. */
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
notBefore: Date;
|
|
21
|
+
notAfter: Date;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Generate a self-signed RSA-2048 X.509 certificate suitable for PDF e-signing.
|
|
25
|
+
*
|
|
26
|
+
* ASCII-only subject — node-forge's `certificateFromPem` mis-counts byte length
|
|
27
|
+
* for non-ASCII values when round-tripping (concrete bug: em-dash in OU breaks
|
|
28
|
+
* parsing with "Too few bytes to parse DER"). The guard here prevents that.
|
|
29
|
+
*/
|
|
30
|
+
export declare function generateSelfSignedCert(opts: GenerateCertOptions): GeneratedCert;
|
|
31
|
+
/**
|
|
32
|
+
* AES-256-GCM-encrypt a PEM-encoded private key for at-rest persistence.
|
|
33
|
+
* Layout: version(2) | salt(16) | iv(12) | authTag(16) | ciphertext.
|
|
34
|
+
*
|
|
35
|
+
* @param keyPem PEM-encoded private key string.
|
|
36
|
+
* @param passphrase ≥24 char high-entropy secret. Derived via scrypt with
|
|
37
|
+
* per-call random salt.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encryptKeyPem(keyPem: string, passphrase: string): Uint8Array;
|
|
40
|
+
/**
|
|
41
|
+
* Inverse of encryptKeyPem. Throws on:
|
|
42
|
+
* - unknown version prefix (future-compat detection)
|
|
43
|
+
* - wrong passphrase (AES-GCM auth-tag mismatch surfaces as error)
|
|
44
|
+
* - tampered ciphertext (same as above)
|
|
45
|
+
*/
|
|
46
|
+
export declare function decryptKeyPem(blob: Uint8Array, passphrase: string): string;
|
|
47
|
+
//# sourceMappingURL=cert-issuer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cert-issuer.d.ts","sourceRoot":"","sources":["../src/cert-issuer.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,MAAM,YAAY,CAAC;AAe/B,MAAM,WAAW,mBAAmB;IAClC,0FAA0F;IAC1F,WAAW,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,OAAO,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5F,iEAAiE;IACjE,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,EAAE,IAAI,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,mBAAmB,GAAG,aAAa,CAuD/E;AAaD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,UAAU,CAW5E;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAiB1E"}
|