@c9up/vellum 0.1.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/LICENSE +21 -0
- package/README.md +473 -0
- package/dist/Vellum.d.ts +460 -0
- package/dist/Vellum.d.ts.map +1 -0
- package/dist/Vellum.js +479 -0
- package/dist/Vellum.js.map +1 -0
- package/dist/VellumProvider.d.ts +28 -0
- package/dist/VellumProvider.d.ts.map +1 -0
- package/dist/VellumProvider.js +33 -0
- package/dist/VellumProvider.js.map +1 -0
- package/dist/augmentations.d.ts +22 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +16 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +17 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +78 -0
- package/dist/configure.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/native/generated.d.ts +255 -0
- package/dist/native/generated.d.ts.map +1 -0
- package/dist/native/generated.js +7 -0
- package/dist/native/generated.js.map +1 -0
- package/dist/native.d.ts +50 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +194 -0
- package/dist/native.js.map +1 -0
- package/dist/responder.d.ts +29 -0
- package/dist/responder.d.ts.map +1 -0
- package/dist/responder.js +101 -0
- package/dist/responder.js.map +1 -0
- package/dist/services/main.d.ts +15 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +39 -0
- package/dist/services/main.js.map +1 -0
- package/dist/signers.d.ts +81 -0
- package/dist/signers.d.ts.map +1 -0
- package/dist/signers.js +98 -0
- package/dist/signers.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +67 -0
- package/scripts/build-napi-types.mjs +69 -0
- package/scripts/copy-napi.mjs +48 -0
- package/scripts/generate-metrics.py +150 -0
- package/scripts/generate-napi-types.mjs +156 -0
- package/src/Vellum.ts +829 -0
- package/src/VellumProvider.ts +51 -0
- package/src/augmentations.ts +25 -0
- package/src/config.ts +47 -0
- package/src/configure.ts +92 -0
- package/src/errors.ts +20 -0
- package/src/index.ts +74 -0
- package/src/native/generated.ts +375 -0
- package/src/native.ts +358 -0
- package/src/responder.ts +116 -0
- package/src/services/main.ts +48 -0
- package/src/signers.ts +149 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C9up
|
|
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,473 @@
|
|
|
1
|
+
# @c9up/vellum
|
|
2
|
+
|
|
3
|
+
PDF toolkit — convert PDF pages to images, author and inspect documents.
|
|
4
|
+
|
|
5
|
+
Agnostic package: the service itself depends on no other part of the
|
|
6
|
+
ecosystem. The provider and the `configure` hook integrate with a Ream host
|
|
7
|
+
through an optional peer dependency.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
ream configure @c9up/vellum
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That registers the provider and writes `config/vellum.ts`.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import vellum from '@c9up/vellum/services/main'
|
|
21
|
+
|
|
22
|
+
// A preview of the first page, 1200px wide
|
|
23
|
+
const preview = await vellum.render(pdf, { page: 1, width: 1200 })
|
|
24
|
+
|
|
25
|
+
// Every page as JPEG
|
|
26
|
+
const pages = await vellum.renderAll(pdf, { format: 'jpeg', quality: 82 })
|
|
27
|
+
|
|
28
|
+
// What is in the document
|
|
29
|
+
const { pageCount, version, encrypted } = await vellum.inspect(pdf)
|
|
30
|
+
const { title, author, createdAt } = await vellum.metadata(pdf)
|
|
31
|
+
const sizes = await vellum.dimensions(pdf)
|
|
32
|
+
|
|
33
|
+
// Its form
|
|
34
|
+
const fields = await vellum.formFields(mandate)
|
|
35
|
+
const filled = await vellum.fillForm(mandate, {
|
|
36
|
+
'assure.nom': 'Amélie Durand',
|
|
37
|
+
accepted: 'Yes',
|
|
38
|
+
})
|
|
39
|
+
const closed = await vellum.flattenForm(filled)
|
|
40
|
+
|
|
41
|
+
// Its text
|
|
42
|
+
const text = await vellum.extractText(pdf, { page: 1 })
|
|
43
|
+
const perPage = await vellum.extractTextAll(pdf)
|
|
44
|
+
|
|
45
|
+
// Reshaping it
|
|
46
|
+
const dossier = await vellum.merge([contract, annexe])
|
|
47
|
+
const extract = await vellum.selectPages(pdf, [1, 3, 4])
|
|
48
|
+
const parts = await vellum.split(pdf)
|
|
49
|
+
const upright = await vellum.rotate(scan, 90, { pages: [1] })
|
|
50
|
+
|
|
51
|
+
// Stamping it — a signature, a photo, a watermark
|
|
52
|
+
const signed = await vellum.stamp(workOrder, signature, {
|
|
53
|
+
page: 1, x: 380, y: 690, width: 140,
|
|
54
|
+
})
|
|
55
|
+
const draft = await vellum.stamp(pdf, watermark, { opacity: 0.15 })
|
|
56
|
+
|
|
57
|
+
// Writing text onto it
|
|
58
|
+
const marked = await vellum.stampText(invoice, 'PAYÉ', {
|
|
59
|
+
x: 400, y: 80, size: 24, color: '#c00', opacity: 0.6,
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Pages are numbered from **1** — the number printed on the page, not an array
|
|
64
|
+
index.
|
|
65
|
+
|
|
66
|
+
### Rendering options
|
|
67
|
+
|
|
68
|
+
| Option | Meaning |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `page` | Which page to render, from 1. Only on `render`. |
|
|
71
|
+
| `scale` | Multiplier over natural size; 1 is 72 DPI. |
|
|
72
|
+
| `width` | Target width in pixels. Takes precedence over `scale`. |
|
|
73
|
+
| `format` | `"png"` (default) or `"jpeg"`. |
|
|
74
|
+
| `quality` | JPEG quality 1-100. Refused without `format: 'jpeg'`. |
|
|
75
|
+
| `background` | `#rgb`, `#rrggbb`, `#rrggbbaa` or `"transparent"`. Default opaque white. |
|
|
76
|
+
|
|
77
|
+
Every option can be defaulted in `config/vellum.ts` and overridden per call.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// config/vellum.ts
|
|
83
|
+
import { defineConfig } from '@c9up/vellum'
|
|
84
|
+
|
|
85
|
+
export default defineConfig({
|
|
86
|
+
format: 'jpeg',
|
|
87
|
+
quality: 82,
|
|
88
|
+
width: 1200,
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Engine
|
|
93
|
+
|
|
94
|
+
The work happens in Rust, behind NAPI, because PDF has no adequate JavaScript
|
|
95
|
+
implementation — this is a capability the platform lacks, not an optimisation
|
|
96
|
+
of one it has.
|
|
97
|
+
|
|
98
|
+
| Crate | Role |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `hayro` | Rasterising a page. Pure Rust, so the binary stays self-contained |
|
|
101
|
+
| `lopdf` | Documents that already exist: object tree, pages, metadata, encryption |
|
|
102
|
+
| `krilla` | Documents we author — and, through its `pdf` feature, re-embedding pages of an existing file |
|
|
103
|
+
| `image` | JPEG encoding |
|
|
104
|
+
|
|
105
|
+
Text extraction rides on hayro's interpreter — it already resolves fonts,
|
|
106
|
+
encodings and `/ToUnicode` maps. `pdf-extract` would have been the obvious
|
|
107
|
+
choice but it pins `lopdf ^0.42` against our 0.44, which would put two copies
|
|
108
|
+
of the parser in the binary.
|
|
109
|
+
|
|
110
|
+
Rasterising an A4 page is around 30ms of pure computation, so it runs on the
|
|
111
|
+
libuv thread pool rather than on the thread serving requests. Every method on
|
|
112
|
+
the service is therefore asynchronous.
|
|
113
|
+
|
|
114
|
+
## Reading text
|
|
115
|
+
|
|
116
|
+
Glyphs come back in the order the page draws them, with a line break where the
|
|
117
|
+
baseline moves. That order is the reading order in practice; no reordering by
|
|
118
|
+
coordinates is attempted, because doing it well needs column detection and
|
|
119
|
+
doing it badly makes multi-column pages worse. No spaces are invented either —
|
|
120
|
+
a PDF encodes its own, and guessing them from gaps duplicates them.
|
|
121
|
+
|
|
122
|
+
A scanned document with no text layer yields an empty string rather than an
|
|
123
|
+
error: it has no text to give.
|
|
124
|
+
|
|
125
|
+
## Reshaping documents
|
|
126
|
+
|
|
127
|
+
`merge`, `selectPages`, `split` and `rotate` all move pages between page
|
|
128
|
+
trees, which is where PDF hides a trap: `Resources`, `MediaBox`, `CropBox` and
|
|
129
|
+
`Rotate` may live on a parent node and be *inherited* by the page. Re-parent
|
|
130
|
+
such a page naively and it loses its size — readers then fall back to Letter,
|
|
131
|
+
quietly resizing an A4 document. Every operation materialises the inherited
|
|
132
|
+
attributes onto the page first.
|
|
133
|
+
|
|
134
|
+
Rotation adds to whatever a page already carries, because a scan can arrive
|
|
135
|
+
already turned.
|
|
136
|
+
|
|
137
|
+
## Stamping
|
|
138
|
+
|
|
139
|
+
`stamp` draws an image onto a document — the signature a technician traces on a
|
|
140
|
+
tablet, a photo attached to a report, a watermark on a draft. PNG and JPEG are
|
|
141
|
+
accepted, chosen by file signature rather than by name. Coordinates count from
|
|
142
|
+
the top-left corner, the way a screen layout is written. Naming no page stamps
|
|
143
|
+
every page, which is what a watermark wants.
|
|
144
|
+
|
|
145
|
+
It works by re-embedding each existing page as a Form XObject and drawing over
|
|
146
|
+
it, through krilla's `pdf` feature. (Krilla's README says embedding existing
|
|
147
|
+
pages is out of scope; its published manifest says otherwise.)
|
|
148
|
+
|
|
149
|
+
`stampText` writes a line of text. It uses the 14 standard fonts — `Helvetica`,
|
|
150
|
+
`Helvetica-Bold`, `Helvetica-Oblique`, `Times-Roman`, `Times-Bold`,
|
|
151
|
+
`Times-Italic`, `Courier`, `Courier-Bold` — which a PDF may reference *without
|
|
152
|
+
embedding*: nothing is added to the file and no font has to be supplied. The
|
|
153
|
+
trade-off is the WinAnsi character set. Western European text is covered,
|
|
154
|
+
accents and typographic punctuation included; anything outside it is refused
|
|
155
|
+
rather than mangled, because silently dropping a character from a contract is
|
|
156
|
+
worse than failing. For `stampText`, `y` is the text's baseline.
|
|
157
|
+
|
|
158
|
+
Text written onto a page is escaped, so a document title cannot inject content
|
|
159
|
+
stream operators.
|
|
160
|
+
|
|
161
|
+
## Stamping and the document underneath
|
|
162
|
+
|
|
163
|
+
`stamp` and `stampText` write into the document that already exists: the
|
|
164
|
+
picture becomes an image XObject named in the page's resources, the text a
|
|
165
|
+
content stream appended to the page. Neither re-authors the file.
|
|
166
|
+
|
|
167
|
+
That is the whole point. Re-authoring — drawing each page onto a fresh one —
|
|
168
|
+
loses everything the page structure carries: the interactive form, the
|
|
169
|
+
annotations, the links. A signature is stamped onto exactly the kind of
|
|
170
|
+
document that has all three.
|
|
171
|
+
|
|
172
|
+
A JPEG goes in untouched, as `DCTDecode`, so a photograph stays the size it
|
|
173
|
+
arrived at. A PNG becomes raw samples, and its alpha channel becomes a soft
|
|
174
|
+
mask — which is what makes a signature drawn on a tablet transparent
|
|
175
|
+
everywhere but the stroke. A CMYK JPEG is refused rather than silently
|
|
176
|
+
inverted.
|
|
177
|
+
|
|
178
|
+
## Fonts
|
|
179
|
+
|
|
180
|
+
`stampText` uses the 14 standard fonts by default. A PDF may reference those
|
|
181
|
+
without embedding them, so nothing is added to the file and no font has to be
|
|
182
|
+
supplied — at the cost of the WinAnsi character set, outside which text is
|
|
183
|
+
refused rather than mangled.
|
|
184
|
+
|
|
185
|
+
A font declared in `config/vellum.ts` is embedded instead:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
// config/vellum.ts
|
|
189
|
+
export default defineConfig({
|
|
190
|
+
fonts: { body: app.makePath('resources/fonts/Inter-Regular.ttf') },
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await vellum.stampText(pdf, 'Uměl Řehoř', { font: 'body' })
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
It is **subsetted to the characters actually written**, because embedding a
|
|
197
|
+
family whole would put megabytes into every stamped document, and a
|
|
198
|
+
`/ToUnicode` table is written alongside it — without one the text is drawn
|
|
199
|
+
correctly and cannot be selected, copied or searched, a loss that only shows up
|
|
200
|
+
when someone tries to read the document back.
|
|
201
|
+
|
|
202
|
+
A configured name is looked up before the standard fonts, so calling one
|
|
203
|
+
`Helvetica` shadows the standard one; a name that is not configured falls
|
|
204
|
+
through, which is what keeps `font: 'Times-Roman'` working with no
|
|
205
|
+
configuration at all. A character the supplied font has no glyph for is
|
|
206
|
+
refused by name rather than dropped.
|
|
207
|
+
|
|
208
|
+
## Interactive forms
|
|
209
|
+
|
|
210
|
+
`formFields` lists a document's AcroForm fields in declaration order. `name` is
|
|
211
|
+
the fully qualified name — every ancestor's partial name joined with dots —
|
|
212
|
+
which is the name a field is filled in by.
|
|
213
|
+
|
|
214
|
+
Two details of PDF 32000-1 §12.7.3 that a caller would otherwise trip on, and
|
|
215
|
+
which this resolves for them: a field's type, flags and value are *inherited*
|
|
216
|
+
down `/Parent`, so a field commonly declares none of them itself; and a
|
|
217
|
+
checkbox or radio's "on" state is chosen by the DOCUMENT (`/Yes`, `/On`, `/1`,
|
|
218
|
+
…), not fixed by the spec. Those accepted states are reported in `options`,
|
|
219
|
+
because writing anything else leaves the control untouched.
|
|
220
|
+
|
|
221
|
+
For a choice field, `options` reports the *exported* values rather than the
|
|
222
|
+
labels — the export value is what gets written back.
|
|
223
|
+
|
|
224
|
+
`fillForm` writes values by their qualified name and **regenerates each filled
|
|
225
|
+
field's appearance stream**. That second half is the one that matters: most
|
|
226
|
+
readers paint a field from its appearance, not from its value, so a document
|
|
227
|
+
filled without it opens looking empty while holding every answer. A checkbox or
|
|
228
|
+
radio already ships one appearance per state, so there only the widget's `/AS`
|
|
229
|
+
is repointed.
|
|
230
|
+
|
|
231
|
+
Refusals are loud rather than silent, because a filled document quietly missing
|
|
232
|
+
an answer is worse than a failure: an unknown field name, a read-only field, a
|
|
233
|
+
value over the declared maximum length, a choice the form does not offer, and a
|
|
234
|
+
checkbox state the document does not accept are all errors.
|
|
235
|
+
|
|
236
|
+
Text is laid out with the **published widths of the standard fonts**, because
|
|
237
|
+
that is what the reader lays it out with. A field's `/Q` is honoured, a
|
|
238
|
+
multiline field wraps at the width of its box, and a `/DA` asking for size 0
|
|
239
|
+
gets a size chosen to fit. A word too long for the line is broken across lines
|
|
240
|
+
rather than left to run past the edge, where the appearance's bounding box
|
|
241
|
+
would clip it away.
|
|
242
|
+
|
|
243
|
+
The widths are generated from the URW base-35 metrics by
|
|
244
|
+
`scripts/generate-metrics.py` and cross-checked against published Adobe values
|
|
245
|
+
in the tests — a table that had drifted would not compile its way into a
|
|
246
|
+
release.
|
|
247
|
+
|
|
248
|
+
`flattenForm` closes the document: every widget's appearance becomes ordinary
|
|
249
|
+
page content, the widget annotations go, and the form itself is dropped. What
|
|
250
|
+
comes back looks the same and can no longer be edited back.
|
|
251
|
+
|
|
252
|
+
The placement follows §12.5.5 — the appearance's `/BBox` transformed by its
|
|
253
|
+
`/Matrix`, and the resulting box mapped onto the annotation's `/Rect`. Painting
|
|
254
|
+
at the rectangle's corner instead would misplace every appearance whose form
|
|
255
|
+
matrix is not the identity, which is most of the ones a real form ships. The
|
|
256
|
+
page's own content is wrapped in `q`/`Q` first: a `cm` outside any pair is
|
|
257
|
+
legal and never restored, so appended content would otherwise inherit a
|
|
258
|
+
transform it never asked for. `stampText` does the same, for the same reason.
|
|
259
|
+
|
|
260
|
+
Annotations that are not form widgets — links, notes — are left where they are;
|
|
261
|
+
flattening removes the form, not the document's other furniture. A hidden
|
|
262
|
+
widget is dropped without being painted, since making visible what a document
|
|
263
|
+
hid is not preservation. And a field holding a value that ships no appearance
|
|
264
|
+
to paint is an error rather than a silent erasure: the answer would vanish from
|
|
265
|
+
a document that still looks complete.
|
|
266
|
+
|
|
267
|
+
## Signing
|
|
268
|
+
|
|
269
|
+
A PDF signature covers a byte range **of the document it lives in**, which
|
|
270
|
+
makes the usual order impossible: the value cannot be computed and then
|
|
271
|
+
assembled, because assembling it would change what it covers. The document is
|
|
272
|
+
written with a hole where the value goes, the `/ByteRange` records everything
|
|
273
|
+
but the hole, and the value is dropped into the reserved space without moving
|
|
274
|
+
another byte.
|
|
275
|
+
|
|
276
|
+
That is also what makes a key you hold and a key held by a certified provider
|
|
277
|
+
the same interface: **a signer never sees the document, only the digest of
|
|
278
|
+
it**.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
// config/vellum.ts
|
|
282
|
+
export default defineConfig({
|
|
283
|
+
signers: {
|
|
284
|
+
internal: myLocalSigner,
|
|
285
|
+
qualified: myProviderSigner,
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const signed = await vellum.sign(mandate, {
|
|
290
|
+
signer: 'qualified',
|
|
291
|
+
reason: 'Mandat de prévoyance',
|
|
292
|
+
name: 'Amélie Durand',
|
|
293
|
+
})
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
A `Signer` is anything with `sign(digest: Buffer): Promise<Buffer>`, returning
|
|
297
|
+
the CMS `SignedData`. Signing over the network belongs there rather than in the
|
|
298
|
+
engine, which does no I/O.
|
|
299
|
+
|
|
300
|
+
The signature is appended as an **incremental revision**: the original bytes
|
|
301
|
+
are preserved exactly. Rewriting the file would invalidate any signature
|
|
302
|
+
already on it and destroy the history a signature exists to establish.
|
|
303
|
+
|
|
304
|
+
A visible signature — a drawn one, an image — is a separate matter: `stamp` it
|
|
305
|
+
on first, then sign.
|
|
306
|
+
|
|
307
|
+
### Signing with a key you hold
|
|
308
|
+
|
|
309
|
+
`pkcs8Signer` is the one signer that ships, because it is the one with no
|
|
310
|
+
vendor behind it:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
signers: {
|
|
314
|
+
internal: pkcs8Signer({
|
|
315
|
+
key: readFileSync(app.makePath('storage/signing.key.der')),
|
|
316
|
+
certificate: readFileSync(app.makePath('storage/signing.crt.der')),
|
|
317
|
+
}),
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
It builds a CAdES `SignedData` whose signed attributes carry the content type,
|
|
322
|
+
the document's digest, the signing time and — as PAdES requires —
|
|
323
|
+
`signing-certificate-v2`. That last one is not decoration: without it a
|
|
324
|
+
signature is bound to a key but not to an identity.
|
|
325
|
+
|
|
326
|
+
PKCS#8 and DER rather than a `.p12`, because reading PKCS#12 in Rust is not
|
|
327
|
+
something to put underneath a signature, and
|
|
328
|
+
`openssl pkcs12 -in bundle.p12 -nodes` gets you there in one command.
|
|
329
|
+
|
|
330
|
+
This is an **advanced** signature: it proves the document has not changed since
|
|
331
|
+
a particular key signed it. Where the law requires a *qualified* one, the key
|
|
332
|
+
has to live with a certified provider — and that is an adapter, not this.
|
|
333
|
+
|
|
334
|
+
### Adapters
|
|
335
|
+
|
|
336
|
+
A provider is a `Signer` and nothing more, so an adapter is a short function
|
|
337
|
+
returning one. None ship here: naming a vendor would tie an agnostic package to
|
|
338
|
+
one, and an HTTP client is not a dependency it should carry.
|
|
339
|
+
|
|
340
|
+
### Timestamping
|
|
341
|
+
|
|
342
|
+
`timestamped` wraps any signer, the local one or a provider's:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
signers: {
|
|
346
|
+
internal: timestamped(pkcs8Signer({ key, certificate }), {
|
|
347
|
+
url: 'https://freetsa.org/tsr',
|
|
348
|
+
}),
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
A signature proves a document has not changed since a key signed it, not
|
|
353
|
+
*when*. Once the certificate expires a verifier cannot tell a signature made
|
|
354
|
+
while it was valid from one forged afterwards, and stops accepting it. For a
|
|
355
|
+
document kept for years — which is most of the documents anyone bothers to
|
|
356
|
+
sign — this is what keeps it verifiable.
|
|
357
|
+
|
|
358
|
+
The token goes on as an **unsigned** attribute, which is what lets it be added
|
|
359
|
+
without disturbing the signature. What comes back is checked rather than
|
|
360
|
+
trusted: the authority's status, that it stamped the signature we actually
|
|
361
|
+
sent, and that it answered *this* request rather than replaying an older
|
|
362
|
+
answer. Those are read out of the token's structure, not looked for in its
|
|
363
|
+
bytes — a hash that happens to appear inside a certificate is not a hash the
|
|
364
|
+
authority stamped. A token that cannot be read is refused, which fails closed.
|
|
365
|
+
|
|
366
|
+
The signature grows by a few kilobytes, so a document prepared with a tight
|
|
367
|
+
`capacity` may need a larger one.
|
|
368
|
+
|
|
369
|
+
### On the dependencies
|
|
370
|
+
|
|
371
|
+
The CMS stack sits on the older RustCrypto generation, which costs a second
|
|
372
|
+
copy of `digest` next to the one lopdf uses. The alternative was the matching
|
|
373
|
+
generation, where `cms` and `rsa` are still release candidates — and a release
|
|
374
|
+
candidate in the one path where correctness is legally load-bearing is a worse
|
|
375
|
+
trade than a duplicated hash, which produces identical bytes by definition.
|
|
376
|
+
One line of `Cargo.toml` to revisit when they ship.
|
|
377
|
+
|
|
378
|
+
## Checking a signature
|
|
379
|
+
|
|
380
|
+
Signing is half the job. A document that arrives signed is worth nothing until
|
|
381
|
+
someone has checked it:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
for (const signature of await vellum.verifySignatures(mandate)) {
|
|
385
|
+
if (!signature.coversWholeDocument) reject('content was added after signing')
|
|
386
|
+
if (!signature.digestMatches) reject('the document has changed')
|
|
387
|
+
if (!signature.signatureVerifies) reject('the signature does not match')
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`coversWholeDocument` is the one that catches the trap everybody meets first:
|
|
392
|
+
**content appended after a signature is not covered by it**, and the arithmetic
|
|
393
|
+
over the covered part still checks out. A reader that verifies only the
|
|
394
|
+
digest will happily call such a document signed. A document whose second half
|
|
395
|
+
arrived later is not one.
|
|
396
|
+
|
|
397
|
+
The report also names the signer, the time they stated, and whether an
|
|
398
|
+
authority has timestamped it.
|
|
399
|
+
|
|
400
|
+
### Trust
|
|
401
|
+
|
|
402
|
+
Checking that a signature matches the certificate it carries says nothing about
|
|
403
|
+
who that certificate belongs to: anyone can make one. Trust comes from a path
|
|
404
|
+
to an anchor you have decided to accept:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// config/vellum.ts
|
|
408
|
+
export default defineConfig({
|
|
409
|
+
trustedAnchors: [readFileSync(app.makePath('storage/anchors/authority.pem'))],
|
|
410
|
+
})
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Supply the roots your jurisdiction's supervisory body publishes — they are
|
|
414
|
+
distributed as trusted lists in the ETSI TS 119 612 format — or your own
|
|
415
|
+
authority's, and `trusted` says whether a path was found. DER or PEM; PEM is
|
|
416
|
+
what an authority usually publishes. Supplying none is a position too: every
|
|
417
|
+
signature then comes back untrusted, which is the honest answer rather than a
|
|
418
|
+
comfortable one.
|
|
419
|
+
|
|
420
|
+
The path is judged **at the moment of signing**, not now: a certificate valid
|
|
421
|
+
when the document was signed and expired since did not retroactively unsign
|
|
422
|
+
anything. `moment` says where that instant came from — `"timestamp"` if an
|
|
423
|
+
authority vouched for it, `"claimed"` if it rests on the signer's own word.
|
|
424
|
+
That is the concrete reason timestamping is worth the round trip.
|
|
425
|
+
|
|
426
|
+
Every link is checked: each certificate is signed by the one above it, each
|
|
427
|
+
issuer says it is an authority, and the signing certificate is allowed to sign
|
|
428
|
+
at all. A path longer than eight is treated as a loop.
|
|
429
|
+
|
|
430
|
+
### Revocation
|
|
431
|
+
|
|
432
|
+
A certificate can be valid on its face and worthless in fact: keys are lost,
|
|
433
|
+
people leave, authorities discover a mistake. Only the issuer knows, and only
|
|
434
|
+
if asked.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
const signatures = await vellum.verifySignatures(mandate, { checkRevocation: true })
|
|
438
|
+
// signatures[0].revocation → { status: 'good' | 'revoked' | 'unknown', detail? }
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
That is a network call per signature, to the responder the certificate names,
|
|
442
|
+
so it is off unless asked for.
|
|
443
|
+
|
|
444
|
+
The answer has **three** values, not two. `unknown` covers everything else —
|
|
445
|
+
the responder was unreachable, answered about a different certificate, or could
|
|
446
|
+
not be believed. Collapsing it into either of the others is the mistake to
|
|
447
|
+
avoid: treating it as good waves a withdrawn certificate through, and treating
|
|
448
|
+
it as revoked rejects documents whenever a server is down. **Which to do is
|
|
449
|
+
your policy**, so it is reported rather than decided.
|
|
450
|
+
|
|
451
|
+
An answer is only read if the issuer, or somebody the issuer authorised to
|
|
452
|
+
answer for it, actually signed it — otherwise anyone could revoke anything. And
|
|
453
|
+
a certificate withdrawn **after** the document was signed does not taint it:
|
|
454
|
+
that is what a signing time, and better a timestamp, is for.
|
|
455
|
+
|
|
456
|
+
## Status
|
|
457
|
+
|
|
458
|
+
Rendering to images, metadata, text extraction, document operations, stamping
|
|
459
|
+
— image and text — supplied fonts, interactive forms read, filled, laid out
|
|
460
|
+
and flattened, and signatures made, timestamped, checked, traced to an
|
|
461
|
+
authority you accept and asked about at their issuer, are complete.
|
|
462
|
+
|
|
463
|
+
What remains needs something this package cannot supply itself: an adapter for
|
|
464
|
+
a certified provider, which is a short function returning a `Signer` and
|
|
465
|
+
belongs to whoever has the account.
|
|
466
|
+
|
|
467
|
+
## Building the native engine
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
pnpm build:napi # cargo build --release + type generation + binary copy
|
|
471
|
+
pnpm test # TypeScript suite
|
|
472
|
+
pnpm test:rust # engine suite
|
|
473
|
+
```
|