@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/dist/Vellum.d.ts +460 -0
  4. package/dist/Vellum.d.ts.map +1 -0
  5. package/dist/Vellum.js +479 -0
  6. package/dist/Vellum.js.map +1 -0
  7. package/dist/VellumProvider.d.ts +28 -0
  8. package/dist/VellumProvider.d.ts.map +1 -0
  9. package/dist/VellumProvider.js +33 -0
  10. package/dist/VellumProvider.js.map +1 -0
  11. package/dist/augmentations.d.ts +22 -0
  12. package/dist/augmentations.d.ts.map +1 -0
  13. package/dist/augmentations.js +16 -0
  14. package/dist/augmentations.js.map +1 -0
  15. package/dist/config.d.ts +33 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +31 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/configure.d.ts +17 -0
  20. package/dist/configure.d.ts.map +1 -0
  21. package/dist/configure.js +78 -0
  22. package/dist/configure.js.map +1 -0
  23. package/dist/errors.d.ts +9 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +19 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/index.d.ts +44 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +44 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/native/generated.d.ts +255 -0
  32. package/dist/native/generated.d.ts.map +1 -0
  33. package/dist/native/generated.js +7 -0
  34. package/dist/native/generated.js.map +1 -0
  35. package/dist/native.d.ts +50 -0
  36. package/dist/native.d.ts.map +1 -0
  37. package/dist/native.js +194 -0
  38. package/dist/native.js.map +1 -0
  39. package/dist/responder.d.ts +29 -0
  40. package/dist/responder.d.ts.map +1 -0
  41. package/dist/responder.js +101 -0
  42. package/dist/responder.js.map +1 -0
  43. package/dist/services/main.d.ts +15 -0
  44. package/dist/services/main.d.ts.map +1 -0
  45. package/dist/services/main.js +39 -0
  46. package/dist/services/main.js.map +1 -0
  47. package/dist/signers.d.ts +81 -0
  48. package/dist/signers.d.ts.map +1 -0
  49. package/dist/signers.js +98 -0
  50. package/dist/signers.js.map +1 -0
  51. package/index.darwin-arm64.node +0 -0
  52. package/index.darwin-x64.node +0 -0
  53. package/index.linux-arm64-gnu.node +0 -0
  54. package/index.linux-x64-gnu.node +0 -0
  55. package/index.win32-x64-msvc.node +0 -0
  56. package/package.json +67 -0
  57. package/scripts/build-napi-types.mjs +69 -0
  58. package/scripts/copy-napi.mjs +48 -0
  59. package/scripts/generate-metrics.py +150 -0
  60. package/scripts/generate-napi-types.mjs +156 -0
  61. package/src/Vellum.ts +829 -0
  62. package/src/VellumProvider.ts +51 -0
  63. package/src/augmentations.ts +25 -0
  64. package/src/config.ts +47 -0
  65. package/src/configure.ts +92 -0
  66. package/src/errors.ts +20 -0
  67. package/src/index.ts +74 -0
  68. package/src/native/generated.ts +375 -0
  69. package/src/native.ts +358 -0
  70. package/src/responder.ts +116 -0
  71. package/src/services/main.ts +48 -0
  72. package/src/signers.ts +149 -0
package/src/native.ts ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Loader for the Rust PDF engine.
3
+ *
4
+ * The engine is not optional. Parsing, authoring and rendering PDF have no
5
+ * JavaScript implementation in this package, so a missing binary is a hard
6
+ * failure with an actionable message — never a silent degradation that lets
7
+ * one deployment behave differently from another.
8
+ */
9
+
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { arch, platform } from "node:process";
13
+ import { fileURLToPath } from "node:url";
14
+ import { VellumError } from "./errors.js";
15
+ import type {
16
+ DocumentInfo,
17
+ DocumentMetadata,
18
+ FormField,
19
+ SignatureOptions as NativeSignatureOptions,
20
+ StampOptions as NativeStampOptions,
21
+ TextStampOptions as NativeTextStampOptions,
22
+ TrustOptions as NativeTrustOptions,
23
+ PageDimensions,
24
+ PageSize,
25
+ PreparedSignature,
26
+ RenderOptions,
27
+ RevocationAnswer,
28
+ SignatureReport,
29
+ TimestampQuery,
30
+ } from "./native/generated.js";
31
+
32
+ const requireNative = createRequire(import.meta.url);
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+
35
+ const platformMap: Record<string, string> = {
36
+ "linux-x64": "linux-x64-gnu",
37
+ "linux-arm64": "linux-arm64-gnu",
38
+ "darwin-x64": "darwin-x64",
39
+ "darwin-arm64": "darwin-arm64",
40
+ "win32-x64": "win32-x64-msvc",
41
+ };
42
+
43
+ /**
44
+ * The engine's surface, as the Rust declares it.
45
+ *
46
+ * Derived from `./native/generated.js` — written by `pnpm build:napi-types`
47
+ * from napi-derive's own `type-def` output — rather than restated here, where
48
+ * nothing would notice a `pub fn` gaining a parameter or changing its return.
49
+ */
50
+ type NativeVellum = typeof import("./native/generated.js");
51
+
52
+ export type {
53
+ DocumentInfo,
54
+ DocumentMetadata,
55
+ FormField,
56
+ PageDimensions,
57
+ PageSize,
58
+ PreparedSignature,
59
+ RevocationAnswer,
60
+ SignatureReport,
61
+ } from "./native/generated.js";
62
+
63
+ let native: NativeVellum | undefined;
64
+ let loadError: unknown;
65
+
66
+ try {
67
+ const suffix = platformMap[`${platform}-${arch}`];
68
+ if (suffix) {
69
+ native = requireNative(join(here, `../index.${suffix}.node`));
70
+ }
71
+ } catch (error) {
72
+ loadError = error;
73
+ }
74
+
75
+ export function isNativeAvailable(): boolean {
76
+ return native !== undefined;
77
+ }
78
+
79
+ /** Why the engine could not be loaded, phrased for whoever has to fix it. */
80
+ function unavailableReason(): string {
81
+ const target = `${platform}-${arch}`;
82
+ if (loadError !== undefined) {
83
+ return `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`;
84
+ }
85
+ return platformMap[target] !== undefined
86
+ ? "binary not found"
87
+ : `no prebuilt binary for ${target}`;
88
+ }
89
+
90
+ /** Raised when an operation needs the Rust engine and it is not there. */
91
+ export class VellumNativeRequiredError extends VellumError {
92
+ constructor() {
93
+ super(
94
+ "NAPI_REQUIRED",
95
+ `The Rust PDF engine is required but not loaded — ${unavailableReason()}.\n` +
96
+ "Install the prebuilt binary for this platform, or build it with `pnpm build:napi`.",
97
+ );
98
+ this.name = "VellumNativeRequiredError";
99
+ }
100
+ }
101
+
102
+ /** The engine, or a refusal explaining how to get one. */
103
+ function engine(): NativeVellum {
104
+ if (native === undefined) throw new VellumNativeRequiredError();
105
+ return native;
106
+ }
107
+
108
+ /**
109
+ * Run engine work, translating its failures into coded errors.
110
+ *
111
+ * The engine reports failures as plain reasons. Without this, a caller
112
+ * catching a corrupt upload would get an `Error` it cannot branch on.
113
+ */
114
+ function run<T>(code: string, work: (engine: NativeVellum) => T): T {
115
+ const loaded = engine();
116
+ try {
117
+ return work(loaded);
118
+ } catch (error) {
119
+ throw new VellumError(
120
+ code,
121
+ error instanceof Error ? error.message : String(error),
122
+ { cause: error },
123
+ );
124
+ }
125
+ }
126
+
127
+ export function inspectNative(pdf: Buffer): DocumentInfo {
128
+ return run("INVALID_PDF", (loaded) => loaded.inspect(pdf));
129
+ }
130
+
131
+ export function createBlankNative(pages: ReadonlyArray<PageSize>): Buffer {
132
+ return run("WRITE_FAILED", (loaded) => loaded.createBlank([...pages]));
133
+ }
134
+
135
+ /**
136
+ * Rasterise one page, addressed from zero.
137
+ *
138
+ * The engine hands this to the libuv thread pool, so the returned promise
139
+ * settles without the calling thread ever blocking.
140
+ */
141
+ export async function renderPageNative(
142
+ pdf: Buffer,
143
+ pageIndex: number,
144
+ options: RenderOptions,
145
+ ): Promise<Buffer> {
146
+ const loaded = engine();
147
+ try {
148
+ return await loaded.renderPage(pdf, pageIndex, options);
149
+ } catch (error) {
150
+ throw new VellumError(
151
+ "RENDER_FAILED",
152
+ error instanceof Error ? error.message : String(error),
153
+ { cause: error },
154
+ );
155
+ }
156
+ }
157
+
158
+ /** Rasterise every page, in document order. */
159
+ export async function renderAllNative(
160
+ pdf: Buffer,
161
+ options: RenderOptions,
162
+ ): Promise<Buffer[]> {
163
+ const loaded = engine();
164
+ try {
165
+ return await loaded.renderAll(pdf, options);
166
+ } catch (error) {
167
+ throw new VellumError(
168
+ "RENDER_FAILED",
169
+ error instanceof Error ? error.message : String(error),
170
+ { cause: error },
171
+ );
172
+ }
173
+ }
174
+
175
+ export function pageDimensionsNative(pdf: Buffer): PageDimensions[] {
176
+ return run("INVALID_PDF", (loaded) => loaded.pageDimensions(pdf));
177
+ }
178
+
179
+ export function metadataNative(pdf: Buffer): DocumentMetadata {
180
+ return run("INVALID_PDF", (loaded) => loaded.metadata(pdf));
181
+ }
182
+
183
+ export async function extractTextNative(
184
+ pdf: Buffer,
185
+ pageIndex: number,
186
+ ): Promise<string> {
187
+ const loaded = engine();
188
+ try {
189
+ return await loaded.extractText(pdf, pageIndex);
190
+ } catch (error) {
191
+ throw new VellumError(
192
+ "EXTRACT_FAILED",
193
+ error instanceof Error ? error.message : String(error),
194
+ { cause: error },
195
+ );
196
+ }
197
+ }
198
+
199
+ export async function extractTextAllNative(pdf: Buffer): Promise<string[]> {
200
+ const loaded = engine();
201
+ try {
202
+ return await loaded.extractTextAll(pdf);
203
+ } catch (error) {
204
+ throw new VellumError(
205
+ "EXTRACT_FAILED",
206
+ error instanceof Error ? error.message : String(error),
207
+ { cause: error },
208
+ );
209
+ }
210
+ }
211
+
212
+ async function edit<T>(
213
+ code: string,
214
+ work: (engine: NativeVellum) => Promise<T>,
215
+ ): Promise<T> {
216
+ const loaded = engine();
217
+ try {
218
+ return await work(loaded);
219
+ } catch (error) {
220
+ throw new VellumError(
221
+ code,
222
+ error instanceof Error ? error.message : String(error),
223
+ { cause: error },
224
+ );
225
+ }
226
+ }
227
+
228
+ export function mergeNative(documents: ReadonlyArray<Buffer>): Promise<Buffer> {
229
+ return edit("MERGE_FAILED", (loaded) => loaded.merge([...documents]));
230
+ }
231
+
232
+ export function selectPagesNative(
233
+ pdf: Buffer,
234
+ pageIndexes: ReadonlyArray<number>,
235
+ ): Promise<Buffer> {
236
+ return edit("SELECT_FAILED", (loaded) =>
237
+ loaded.selectPages(pdf, [...pageIndexes]),
238
+ );
239
+ }
240
+
241
+ export function splitNative(pdf: Buffer): Promise<Buffer[]> {
242
+ return edit("SPLIT_FAILED", (loaded) => loaded.split(pdf));
243
+ }
244
+
245
+ export function rotateNative(
246
+ pdf: Buffer,
247
+ degrees: number,
248
+ pageIndexes?: ReadonlyArray<number>,
249
+ ): Promise<Buffer> {
250
+ return edit("ROTATE_FAILED", (loaded) =>
251
+ loaded.rotate(pdf, degrees, pageIndexes ? [...pageIndexes] : undefined),
252
+ );
253
+ }
254
+
255
+ export function stampNative(
256
+ pdf: Buffer,
257
+ image: Buffer,
258
+ options: NativeStampOptions,
259
+ ): Promise<Buffer> {
260
+ return edit("STAMP_FAILED", (loaded) => loaded.stamp(pdf, image, options));
261
+ }
262
+
263
+ export function stampTextNative(
264
+ pdf: Buffer,
265
+ text: string,
266
+ options: NativeTextStampOptions,
267
+ ): Promise<Buffer> {
268
+ return edit("STAMP_TEXT_FAILED", (loaded) =>
269
+ loaded.stampText(pdf, text, options),
270
+ );
271
+ }
272
+
273
+ export function formFieldsNative(pdf: Buffer): FormField[] {
274
+ return run("INVALID_PDF", (loaded) => loaded.formFields(pdf));
275
+ }
276
+
277
+ export function fillFormNative(
278
+ pdf: Buffer,
279
+ values: Record<string, string>,
280
+ ): Promise<Buffer> {
281
+ return edit("FILL_FAILED", (loaded) => loaded.fillForm(pdf, values));
282
+ }
283
+
284
+ export function flattenFormNative(pdf: Buffer): Promise<Buffer> {
285
+ return edit("FLATTEN_FAILED", (loaded) => loaded.flattenForm(pdf));
286
+ }
287
+
288
+ export function prepareSignatureNative(
289
+ pdf: Buffer,
290
+ options: NativeSignatureOptions,
291
+ ): Promise<PreparedSignature> {
292
+ return edit("SIGN_FAILED", (loaded) => loaded.prepareSignature(pdf, options));
293
+ }
294
+
295
+ export function embedSignatureNative(
296
+ prepared: Buffer,
297
+ value: Buffer,
298
+ ): Promise<Buffer> {
299
+ return edit("SIGN_FAILED", (loaded) =>
300
+ loaded.embedSignature(prepared, value),
301
+ );
302
+ }
303
+
304
+ export function signCmsNative(
305
+ digest: Buffer,
306
+ key: Buffer,
307
+ certificates: ReadonlyArray<Buffer>,
308
+ signedAt: string,
309
+ ): Promise<Buffer> {
310
+ return edit("SIGN_FAILED", (loaded) =>
311
+ loaded.signCms(digest, key, [...certificates], signedAt),
312
+ );
313
+ }
314
+
315
+ export function timestampQueryNative(cms: Buffer): TimestampQuery {
316
+ return run("TIMESTAMP_FAILED", (loaded) => loaded.timestampQuery(cms));
317
+ }
318
+
319
+ export function attachTimestampNative(
320
+ cms: Buffer,
321
+ response: Buffer,
322
+ nonce: Buffer,
323
+ ): Buffer {
324
+ return run("TIMESTAMP_FAILED", (loaded) =>
325
+ loaded.attachTimestamp(cms, response, nonce),
326
+ );
327
+ }
328
+
329
+ export function verifySignaturesNative(
330
+ pdf: Buffer,
331
+ trust: NativeTrustOptions,
332
+ ): Promise<SignatureReport[]> {
333
+ return edit("VERIFY_FAILED", (loaded) => loaded.verifySignatures(pdf, trust));
334
+ }
335
+
336
+ export function responderUrlNative(certificate: Buffer): string | null {
337
+ return run("VERIFY_FAILED", (loaded) => loaded.responderUrl(certificate));
338
+ }
339
+
340
+ export function revocationQueryNative(
341
+ certificate: Buffer,
342
+ issuer: Buffer,
343
+ ): Buffer {
344
+ return run("VERIFY_FAILED", (loaded) =>
345
+ loaded.revocationQuery(certificate, issuer),
346
+ );
347
+ }
348
+
349
+ export function readRevocationNative(
350
+ response: Buffer,
351
+ certificate: Buffer,
352
+ issuer: Buffer,
353
+ at: number | null,
354
+ ): RevocationAnswer {
355
+ return run("VERIFY_FAILED", (loaded) =>
356
+ loaded.readRevocation(response, certificate, issuer, at ?? undefined),
357
+ );
358
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Deciding whether a revocation responder may be contacted.
3
+ *
4
+ * The address comes out of the certificate inside the document being checked,
5
+ * which is to say: from whoever sent the document. Following it unconditionally
6
+ * turns "check whether this signature is still valid" into "make my server
7
+ * issue an HTTP request wherever this stranger points" — the cloud metadata
8
+ * endpoint, a Redis on loopback, or simply a URL that confirms the document was
9
+ * opened and reveals the address it was opened from.
10
+ *
11
+ * So the default is a public responder over HTTP, and an application whose
12
+ * authority answers on the internal network says so by name. Everything
13
+ * refused answers `"unknown"`, never `"good"`: a responder that was not asked
14
+ * has told us nothing.
15
+ */
16
+
17
+ /** Who an application is willing to ask about revocation. */
18
+ export type ResponderPolicy =
19
+ | ReadonlyArray<string>
20
+ | ((url: URL) => boolean | Promise<boolean>);
21
+
22
+ /** Hosts that are never a public certificate authority. */
23
+ function isPrivateHost(hostname: string): boolean {
24
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
25
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
26
+ // IPv6 loopback, unspecified, unique-local (fc00::/7) and link-local (fe80::/10).
27
+ if (host === "::1" || host === "::") return true;
28
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
29
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
30
+ // An IPv4-mapped IPv6 address is the same machine under another spelling,
31
+ // and the URL parser rewrites `::ffff:127.0.0.1` as `::ffff:7f00:1` — so a
32
+ // check that only reads dotted quads waves loopback straight through.
33
+ const mapped =
34
+ /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(host) ??
35
+ (() => {
36
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
37
+ if (hex === null) return null;
38
+ const packed =
39
+ (Number.parseInt(hex[1] as string, 16) << 16) |
40
+ Number.parseInt(hex[2] as string, 16);
41
+ return [
42
+ host,
43
+ [24, 16, 8, 0].map((shift) => (packed >>> shift) & 0xff).join("."),
44
+ ];
45
+ })();
46
+ const literal = mapped?.[1] ?? host;
47
+ const parts = literal.split(".");
48
+ if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p)))
49
+ return false;
50
+ const [a, b] = parts.map(Number) as [number, number, number, number];
51
+ return (
52
+ a === 0 || // this network
53
+ a === 127 || // loopback
54
+ a === 10 || // private
55
+ (a === 172 && b >= 16 && b <= 31) || // private
56
+ (a === 192 && b === 168) || // private
57
+ (a === 169 && b === 254) || // link-local, and the cloud metadata address
58
+ (a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
59
+ a >= 224 // multicast and reserved
60
+ );
61
+ }
62
+
63
+ /** Why a responder was refused, in a sentence a caller can act on. */
64
+ export interface ResponderRefusal {
65
+ refused: string;
66
+ }
67
+
68
+ /**
69
+ * Decide whether `url` may be asked.
70
+ *
71
+ * @param policy What the application allows: a list of hostnames, or a
72
+ * predicate. Absent, only public hosts over http/https are contacted.
73
+ */
74
+ export async function mayAsk(
75
+ url: string,
76
+ policy?: ResponderPolicy,
77
+ ): Promise<URL | ResponderRefusal> {
78
+ let parsed: URL;
79
+ try {
80
+ parsed = new URL(url);
81
+ } catch {
82
+ return { refused: `the certificate names "${url}", which is not a URL` };
83
+ }
84
+
85
+ if (typeof policy === "function") {
86
+ return (await policy(parsed))
87
+ ? parsed
88
+ : { refused: `the configured policy refused ${parsed.origin}` };
89
+ }
90
+ if (policy !== undefined) {
91
+ return policy.includes(parsed.hostname)
92
+ ? parsed
93
+ : {
94
+ refused:
95
+ `${parsed.hostname} is not among the responders this application allows` +
96
+ ` (${policy.join(", ") || "none"})`,
97
+ };
98
+ }
99
+
100
+ // OCSP is defined over HTTP. Anything else is a scheme somebody wanted the
101
+ // server to speak, not a way to ask about a certificate.
102
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
103
+ return {
104
+ refused: `the certificate names a ${parsed.protocol.replace(":", "")} responder, which is not asked`,
105
+ };
106
+ }
107
+ if (isPrivateHost(parsed.hostname)) {
108
+ return {
109
+ refused:
110
+ `the certificate names ${parsed.hostname}, an address on this network — ` +
111
+ "a document does not get to choose which of your own services is contacted. " +
112
+ "Name it in `allowedResponders` if that really is your authority.",
113
+ };
114
+ }
115
+ return parsed;
116
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Container service accessor — `import vellum from '@c9up/vellum/services/main'`.
3
+ *
4
+ * Populated by `VellumProvider.boot()`. Reading it before the provider has
5
+ * booted throws rather than answering with an unconfigured service, which
6
+ * would render with defaults nobody chose.
7
+ */
8
+
9
+ import type { Vellum } from "../Vellum.js";
10
+
11
+ let instance: Vellum | undefined;
12
+
13
+ /** @internal Called by the provider once the service exists. */
14
+ export function setVellum(service: Vellum): void {
15
+ instance = service;
16
+ }
17
+
18
+ /** @internal Test helper — forget the service between cases. */
19
+ export function clearVellum(): void {
20
+ instance = undefined;
21
+ }
22
+
23
+ function resolve(): Vellum {
24
+ if (!instance) {
25
+ throw new Error(
26
+ "[vellum] Vellum service accessed before VellumProvider.boot() ran. " +
27
+ "Check that `@c9up/vellum/provider` is listed in your reamrc.ts providers.",
28
+ );
29
+ }
30
+ return instance;
31
+ }
32
+
33
+ const vellum: Vellum = new Proxy(Object.create(null), {
34
+ get(_target, property) {
35
+ // A module loader probes an imported binding for `then` to see whether
36
+ // it is thenable, and for well-known symbols. Answering those by
37
+ // resolving the service would throw during the import itself, before
38
+ // any provider had a chance to boot.
39
+ if (property === "then" || typeof property === "symbol") {
40
+ return undefined;
41
+ }
42
+ const service = resolve();
43
+ const value = Reflect.get(service, property, service);
44
+ return typeof value === "function" ? value.bind(service) : value;
45
+ },
46
+ });
47
+
48
+ export default vellum;
package/src/signers.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Signers that need nothing but a key you hold.
3
+ *
4
+ * A signer bound to a certified provider is not here and should not be: it
5
+ * belongs to whoever has the account, as an adapter of its own. This package
6
+ * ships the contract and the one implementation that has no vendor behind it.
7
+ */
8
+
9
+ import { VellumError } from "./errors.js";
10
+ import {
11
+ attachTimestampNative,
12
+ signCmsNative,
13
+ timestampQueryNative,
14
+ } from "./native.js";
15
+ import type { Signer } from "./Vellum.js";
16
+
17
+ /** A key you hold, and the certificate that vouches for it. */
18
+ export interface Pkcs8SignerOptions {
19
+ /** The private key, PKCS#8 DER. */
20
+ key: Buffer;
21
+ /** The signer's certificate, DER. */
22
+ certificate: Buffer;
23
+ /**
24
+ * The rest of the chain, DER, nearest issuer first.
25
+ *
26
+ * Worth supplying: a verifier that has to go and find the issuers itself
27
+ * will often decide it cannot.
28
+ */
29
+ chain?: ReadonlyArray<Buffer>;
30
+ }
31
+
32
+ /**
33
+ * Sign with a key held by the application.
34
+ *
35
+ * ```ts
36
+ * // config/vellum.ts
37
+ * signers: {
38
+ * internal: pkcs8Signer({
39
+ * key: readFileSync(app.makePath('storage/signing.key.der')),
40
+ * certificate: readFileSync(app.makePath('storage/signing.crt.der')),
41
+ * }),
42
+ * }
43
+ * ```
44
+ *
45
+ * This is an *advanced* signature: it proves the document has not changed
46
+ * since a particular key signed it. Whether that is enough is a question about
47
+ * the document, not about the code — where the law requires a qualified
48
+ * signature, the key has to live with a certified provider, and that is an
49
+ * adapter rather than this.
50
+ *
51
+ * PKCS#8 and DER rather than a `.p12` bundle: reading PKCS#12 in Rust is not
52
+ * something to put underneath a signature, and
53
+ * `openssl pkcs12 -in bundle.p12 -nodes` gets you here in one command.
54
+ */
55
+ export function pkcs8Signer(options: Pkcs8SignerOptions): Signer {
56
+ if (options.key.length === 0) {
57
+ throw new VellumError("SIGNER_INVALID", "The signing key is empty.");
58
+ }
59
+ if (options.certificate.length === 0) {
60
+ throw new VellumError("SIGNER_INVALID", "The certificate is empty.");
61
+ }
62
+
63
+ const certificates = [options.certificate, ...(options.chain ?? [])];
64
+ return {
65
+ async sign(digest: Buffer): Promise<Buffer> {
66
+ return signCmsNative(
67
+ digest,
68
+ options.key,
69
+ certificates,
70
+ new Date().toISOString(),
71
+ );
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Where to ask for a timestamp, and how. */
77
+ export interface TimestampOptions {
78
+ /** The authority's RFC 3161 endpoint. */
79
+ url: string;
80
+ /** Anything the authority needs, such as an Authorization header. */
81
+ headers?: Record<string, string>;
82
+ /** How long to wait before giving up. Default 10 seconds. */
83
+ timeoutMs?: number;
84
+ }
85
+
86
+ /**
87
+ * Add a trusted timestamp to whatever `inner` signs.
88
+ *
89
+ * ```ts
90
+ * signers: {
91
+ * internal: timestamped(pkcs8Signer({ key, certificate }), {
92
+ * url: 'https://freetsa.org/tsr',
93
+ * }),
94
+ * }
95
+ * ```
96
+ *
97
+ * A signature proves a document has not changed since a key signed it, not
98
+ * *when*. Once the signing certificate expires, a verifier cannot tell a
99
+ * signature made while it was valid from one forged afterwards, and stops
100
+ * accepting it. For a document kept for years — which is most of the documents
101
+ * anyone bothers to sign — a timestamp is what keeps it verifiable.
102
+ *
103
+ * It wraps any signer, so it works over a provider's as well as the local one.
104
+ * The token goes on as an unsigned attribute, which is what lets it be added
105
+ * without disturbing the signature.
106
+ *
107
+ * The signature grows by a few kilobytes, so a document prepared with a tight
108
+ * `capacity` may need a larger one.
109
+ */
110
+ export function timestamped(inner: Signer, options: TimestampOptions): Signer {
111
+ return {
112
+ async sign(digest: Buffer): Promise<Buffer> {
113
+ const cms = await inner.sign(digest);
114
+ const { query, nonce } = timestampQueryNative(cms);
115
+
116
+ let answer: Response;
117
+ try {
118
+ answer = await fetch(options.url, {
119
+ method: "POST",
120
+ headers: {
121
+ "content-type": "application/timestamp-query",
122
+ ...options.headers,
123
+ },
124
+ body: new Uint8Array(query),
125
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
126
+ });
127
+ } catch (error) {
128
+ throw new VellumError(
129
+ "TIMESTAMP_UNREACHABLE",
130
+ `The timestamp authority at ${options.url} could not be reached.`,
131
+ { cause: error },
132
+ );
133
+ }
134
+
135
+ if (!answer.ok) {
136
+ throw new VellumError(
137
+ "TIMESTAMP_REFUSED",
138
+ `The timestamp authority at ${options.url} answered ${answer.status}.`,
139
+ );
140
+ }
141
+
142
+ return attachTimestampNative(
143
+ cms,
144
+ Buffer.from(await answer.arrayBuffer()),
145
+ nonce,
146
+ );
147
+ },
148
+ };
149
+ }