@pipobscure/bundle 0.0.1

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 (98) hide show
  1. package/HISTORY.md +1924 -0
  2. package/README.md +623 -0
  3. package/bundle.run +0 -0
  4. package/dist/api.d.ts +147 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +174 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/archive.d.ts +115 -0
  9. package/dist/archive.d.ts.map +1 -0
  10. package/dist/archive.js +188 -0
  11. package/dist/archive.js.map +1 -0
  12. package/dist/audit.d.ts +78 -0
  13. package/dist/audit.d.ts.map +1 -0
  14. package/dist/audit.js +119 -0
  15. package/dist/audit.js.map +1 -0
  16. package/dist/cli.d.ts +23 -0
  17. package/dist/cli.d.ts.map +1 -0
  18. package/dist/cli.js +555 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/files.d.ts +53 -0
  21. package/dist/files.d.ts.map +1 -0
  22. package/dist/files.js +118 -0
  23. package/dist/files.js.map +1 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +35 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/launch.d.ts +97 -0
  29. package/dist/launch.d.ts.map +1 -0
  30. package/dist/launch.js +267 -0
  31. package/dist/launch.js.map +1 -0
  32. package/dist/main.d.ts +3 -0
  33. package/dist/main.d.ts.map +1 -0
  34. package/dist/main.js +19 -0
  35. package/dist/main.js.map +1 -0
  36. package/dist/manifest.d.ts +139 -0
  37. package/dist/manifest.d.ts.map +1 -0
  38. package/dist/manifest.js +504 -0
  39. package/dist/manifest.js.map +1 -0
  40. package/dist/oidc.d.ts +40 -0
  41. package/dist/oidc.d.ts.map +1 -0
  42. package/dist/oidc.js +320 -0
  43. package/dist/oidc.js.map +1 -0
  44. package/dist/preload.d.ts +14 -0
  45. package/dist/preload.d.ts.map +1 -0
  46. package/dist/preload.js +38 -0
  47. package/dist/preload.js.map +1 -0
  48. package/dist/provider.d.ts +83 -0
  49. package/dist/provider.d.ts.map +1 -0
  50. package/dist/provider.js +206 -0
  51. package/dist/provider.js.map +1 -0
  52. package/dist/record.d.ts +2 -0
  53. package/dist/record.d.ts.map +1 -0
  54. package/dist/record.js +23 -0
  55. package/dist/record.js.map +1 -0
  56. package/dist/recorder.d.ts +64 -0
  57. package/dist/recorder.d.ts.map +1 -0
  58. package/dist/recorder.js +111 -0
  59. package/dist/recorder.js.map +1 -0
  60. package/dist/register.d.ts +2 -0
  61. package/dist/register.d.ts.map +1 -0
  62. package/dist/register.js +28 -0
  63. package/dist/register.js.map +1 -0
  64. package/dist/sea.d.ts +97 -0
  65. package/dist/sea.d.ts.map +1 -0
  66. package/dist/sea.js +220 -0
  67. package/dist/sea.js.map +1 -0
  68. package/dist/sigstore.d.ts +112 -0
  69. package/dist/sigstore.d.ts.map +1 -0
  70. package/dist/sigstore.js +385 -0
  71. package/dist/sigstore.js.map +1 -0
  72. package/dist/skill.d.ts +36 -0
  73. package/dist/skill.d.ts.map +1 -0
  74. package/dist/skill.js +108 -0
  75. package/dist/skill.js.map +1 -0
  76. package/package.json +84 -0
  77. package/shell-base +2 -0
  78. package/skills/audit-bundle/SKILL.md +271 -0
  79. package/src/api.ts +293 -0
  80. package/src/archive.ts +312 -0
  81. package/src/audit.ts +206 -0
  82. package/src/cli.ts +575 -0
  83. package/src/files.ts +156 -0
  84. package/src/index.ts +114 -0
  85. package/src/launch.ts +336 -0
  86. package/src/main.ts +20 -0
  87. package/src/manifest.ts +615 -0
  88. package/src/oidc.ts +372 -0
  89. package/src/preload.ts +40 -0
  90. package/src/provider.ts +270 -0
  91. package/src/record.ts +25 -0
  92. package/src/recorder.ts +166 -0
  93. package/src/register.ts +30 -0
  94. package/src/sea.ts +341 -0
  95. package/src/sigstore.ts +492 -0
  96. package/src/skill.ts +132 -0
  97. package/src/types/node-vfs.d.ts +90 -0
  98. package/src/types/node-zip.d.ts +85 -0
@@ -0,0 +1,615 @@
1
+ import * as ZLIB from 'node:zlib';
2
+ import * as CRYPTO from 'node:crypto';
3
+ import * as TLS from 'node:tls';
4
+ import * as FS from 'node:fs';
5
+ import * as SIGSTORE from './sigstore.ts';
6
+
7
+ // The signature is staged so a verifier can gate cheaply before doing more:
8
+ //
9
+ // * Integrity — a single hash covers the *entire file*: the prepended
10
+ // launcher/binary (when there is one), every member, the whole central
11
+ // directory (member comments included) and the fixed part of the
12
+ // end-of-central-directory record, up to but excluding the EOCD's 2-byte
13
+ // comment-length field. The EOCD must be the last thing in the file, so the
14
+ // hashed region is simply everything before its trailing comment.
15
+ //
16
+ // * Authenticity — the leaf certificate signs that hash (not the file), so
17
+ // once the hash is known the signature check needs no re-read of the file.
18
+ //
19
+ // * Per-member integrity — every member also carries the hex digest of its
20
+ // own content in its ZIP entry comment, the same guarantee applied one file
21
+ // at a time. This is what `provider.ts` re-checks on each member fetch, so
22
+ // content handed to a running program is verified as it is read and not
23
+ // merely at the moment the archive was mounted.
24
+ //
25
+ // Both are recorded in the EOCD comment — which the whole-file hash stops short
26
+ // of — as a single marker:
27
+ //
28
+ // SIGNED:<hash-of-region-hex>:<signature-hex>[:<NAME>=<value>]*
29
+ //
30
+ // A verifier can validate the hash on its own (a pre-mount integrity gate),
31
+ // then check the signature over that hash against the certificate, and only a
32
+ // signed archive (one carrying this marker) is gated at all.
33
+ //
34
+ // The trailing `NAME=value` fields are the unsigned-attribute region this
35
+ // format needs and every code-signing scheme eventually grows: anything
36
+ // obtained *after* the signature exists cannot be inside what the signature
37
+ // covers, so it goes here instead — the same placement RFC 3161 timestamp
38
+ // tokens get in CMS `unsignedAttrs`. Today the only field is `SIGSTORE=`, a
39
+ // base64 sigstore bundle carrying the transparency-log entry and timestamp
40
+ // that establish *when* the archive was signed. Fields are optional and
41
+ // unknown ones are ignored, so a two-field marker written by an older version
42
+ // still parses.
43
+ //
44
+ // The manifest is the `AUTHORITY.PEM` member: it declares the algorithms and
45
+ // carries the certificate chain (the signing authority) — hence the name, which
46
+ // is also a real, extractable filename a plain zip utility will happily pull
47
+ // out when auditing:
48
+ //
49
+ // !manifest 2 <- magic + format version
50
+ // !hash sha256 <- digest for the whole-file hash and members
51
+ // !sign sha256 <- digest the signature (over that hash) uses
52
+ // <- blank line (present only when signed)
53
+ // -----BEGIN CERTIFICATE----- <- full PEM chain, leaf first, embedded so
54
+ // ... a verifier is self-contained
55
+
56
+ const MAGIC = 'manifest';
57
+ const VERSION = '2';
58
+ export const AUTHORITY = 'AUTHORITY.PEM';
59
+ const SIG_EOCD = 0x06054b50;
60
+ const CHUNK = 1 << 20;
61
+
62
+ /** What a verification concluded, in increasing order of confidence. */
63
+ export type VerificationState = 'unsigned' | 'invalid' | 'valid-untrusted' | 'valid';
64
+
65
+ /** The archive-side surface of `AUTHORITY.PEM`. */
66
+ export interface ManifestFields {
67
+ /** Format version, absent when the member was not a manifest at all. */
68
+ version?: string | undefined;
69
+ /** Digest used for the whole-file hash and for member digests. */
70
+ hashAlg: string;
71
+ /** Digest the signature over the whole-file hash uses; absent when unsigned. */
72
+ signAlg?: string | undefined;
73
+ /** The certificate chain, leaf first; empty when unsigned. */
74
+ chain: CRYPTO.X509Certificate[];
75
+ }
76
+
77
+ /** A parsed `SIGNED:<hash>:<sig>[:<NAME>=<value>]*` marker. */
78
+ export interface SignatureMarker {
79
+ hash: string;
80
+ sig: string;
81
+ /** The trailing unsigned attributes, keyed by upper-case name. */
82
+ fields: Map<string, string>;
83
+ }
84
+
85
+ export interface VerificationResult {
86
+ state: VerificationState;
87
+ reason: string;
88
+ /** Subject of the leaf certificate, when the archive named one. */
89
+ subject?: string | undefined;
90
+ /** True for anything but `unsigned` — the archive claimed a signature. */
91
+ signed: boolean;
92
+ /** True only for `valid`. */
93
+ trusted: boolean;
94
+ hashAlg?: string | undefined;
95
+ /** Member name -> recorded hex digest, from the archive that was hashed. */
96
+ digests?: Map<string, string> | undefined;
97
+ /** True when the archive was signed through sigstore. */
98
+ sigstore?: boolean | undefined;
99
+ /** The sigstore signing identity (a SAN), once established. */
100
+ identity?: string | undefined;
101
+ /** The sigstore OIDC issuer, once established. */
102
+ issuer?: string | undefined;
103
+ /** When the signature was witnessed, per the transparency log. */
104
+ signedAt?: Date | undefined;
105
+ }
106
+
107
+ export interface VerifyOptions {
108
+ /** Additional trusted PEM roots, besides system + NODE_EXTRA_CA_CERTS. */
109
+ extraRoots?: string[] | undefined;
110
+ /**
111
+ * Reference time for certificate validity (default: now). Ignored on the
112
+ * sigstore path, which derives the signing time from the archive's own log
113
+ * entry instead.
114
+ */
115
+ now?: number | undefined;
116
+ /**
117
+ * Also recompute every member's content digest (default: true). With
118
+ * `false` only the presence of the digests is checked; the whole-file hash
119
+ * already covers the members' bytes, so this is the right trade for a mount
120
+ * that re-checks each member as it is actually read.
121
+ */
122
+ deep?: boolean | undefined;
123
+ /**
124
+ * An already-open archive over `source` to read entries from. When given it
125
+ * is left open for the caller; otherwise one is opened and closed here.
126
+ */
127
+ archive?: ZLIB.ZipFile | ZLIB.ZipBuffer | undefined;
128
+ /**
129
+ * Path to a sigstore trust root, for sigstore-signed archives
130
+ * (default: BUNDLE_SIGSTORE_ROOT, else the TUF cache, else the seed).
131
+ */
132
+ trustedRoot?: string | undefined;
133
+ /** Require this sigstore signing identity (SAN). */
134
+ identity?: string | undefined;
135
+ /** Require this sigstore OIDC issuer. */
136
+ issuer?: string | undefined;
137
+ }
138
+
139
+ /** An archive to be built or verified: a path on disk, or the bytes themselves. */
140
+ export type ArchiveSource = string | Buffer;
141
+
142
+ /**
143
+ * How each state is reported, and the exit code that goes with it.
144
+ *
145
+ * The codes are part of the CLI's contract — a script branching on
146
+ * `bundle verify` depends on them — so they live here with the states they
147
+ * describe rather than in `cli.ts`. That also keeps the `bundle` launcher from
148
+ * having to load the CLI to find out what exit code a refusal deserves.
149
+ */
150
+ export const STATES: Record<VerificationState, { code: number; label: string; note: string }> = {
151
+ 'unsigned': { code: 3, label: 'UNSIGNED', note: 'archive carries no signature' },
152
+ 'invalid': { code: 2, label: 'INVALID', note: 'manifest is wrong or does not cover the whole archive' },
153
+ 'valid-untrusted': { code: 1, label: 'VALID (UNTRUSTED)', note: 'signature is good but the certificate is not trusted' },
154
+ 'valid': { code: 0, label: 'VALID', note: 'signature is good and the certificate is trusted' },
155
+ };
156
+
157
+ // Build the manifest content from the algorithms and (when signing) the
158
+ // certificate chain.
159
+ export function buildManifest({ hashAlg = 'sha256', signAlg, chain }: {
160
+ hashAlg?: string | undefined;
161
+ signAlg?: string | undefined;
162
+ chain?: string | undefined;
163
+ } = {}): Buffer {
164
+ let body = `!${MAGIC} ${VERSION}\n!hash ${hashAlg}\n`;
165
+ if (signAlg && chain) body += `!sign ${signAlg}\n\n` + String(chain).replace(/\s*$/, '') + '\n';
166
+ return Buffer.from(body, 'utf-8');
167
+ }
168
+
169
+ // Split manifest bytes into { version, hashAlg, signAlg, chain }.
170
+ export function parseManifest(content: Buffer | string): ManifestFields {
171
+ const text = Buffer.isBuffer(content) ? content.toString('utf-8') : String(content);
172
+ const split = text.indexOf('\n\n');
173
+ const head = split >= 0 ? text.slice(0, split) : text;
174
+ const pem = split >= 0 ? text.slice(split + 2) : '';
175
+ const directives: Record<string, string> = {};
176
+ for (const line of head.split('\n')) {
177
+ if (!line || line[0] !== '!') continue;
178
+ const sp = line.indexOf(' ');
179
+ if (sp < 0) directives[line.slice(1)] = '';
180
+ else directives[line.slice(1, sp)] = line.slice(sp + 1);
181
+ }
182
+ const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
183
+ return {
184
+ version: directives[MAGIC],
185
+ hashAlg: directives['hash'] || 'sha256',
186
+ signAlg: directives['sign'],
187
+ chain: blocks.map((block) => new CRYPTO.X509Certificate(block)),
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Verify an archive. `source` is a filesystem path (opened read-only and read
193
+ * in chunks) or a Buffer of the whole file.
194
+ *
195
+ * `digests` on the result is the member-name -> hex-digest map read from the
196
+ * entry comments of the very archive that was just hashed. A caller that goes
197
+ * on to serve those members (see provider.ts) should keep this map rather than
198
+ * re-reading the comments later, so what it checks content against is what the
199
+ * signature covered.
200
+ */
201
+ export function verifySync(source: ArchiveSource, options: VerifyOptions = {}): VerificationResult {
202
+ const io = Buffer.isBuffer(source) ? bufferSource(source) : pathSource(source);
203
+ let reader: ArchiveReader | undefined;
204
+ try {
205
+ reader = readerFor(options.archive ?? io.open());
206
+ return inspect(reader, io, options);
207
+ } catch (err) {
208
+ // An archive whose structure no longer holds together has been altered
209
+ // as surely as one whose hash is wrong, so it gets the same answer. A
210
+ // file that is missing or unreadable is still an error: there is
211
+ // nothing there to call invalid.
212
+ if (isZipError(err)) return result('invalid', `not a readable ZIP archive: ${message(err)}`);
213
+ throw err;
214
+ } finally {
215
+ if (!options.archive) reader?.close();
216
+ }
217
+ }
218
+
219
+ function isZipError(err: unknown): boolean {
220
+ const code = (err as { code?: unknown } | null)?.code;
221
+ return typeof code === 'string' && code.startsWith('ERR_ZIP_');
222
+ }
223
+
224
+ /**
225
+ * Promise-returning wrapper around `verifySync`, for callers that treat
226
+ * verification as an asynchronous step. Hashing is CPU-bound either way, so the
227
+ * work still runs to completion synchronously.
228
+ */
229
+ export async function verify(source: ArchiveSource, options?: VerifyOptions): Promise<VerificationResult> {
230
+ return verifySync(source, options);
231
+ }
232
+
233
+ // A reader over either archive flavour. `ZipFile` and `ZipBuffer` expose the
234
+ // same information under different names — `entriesSync()` against `entries()`,
235
+ // `getSync()` against `get()` — and only one of them can be closed.
236
+ interface ArchiveReader {
237
+ entries(): Iterable<[string, ZLIB.ZipEntry]>;
238
+ close(): void;
239
+ }
240
+
241
+ function readerFor(archive: ZLIB.ZipFile | ZLIB.ZipBuffer): ArchiveReader {
242
+ if (archive instanceof ZLIB.ZipBuffer) {
243
+ return { entries: () => archive.entries(), close: () => {} };
244
+ }
245
+ return { entries: () => archive.entriesSync(), close: () => archive.closeSync() };
246
+ }
247
+
248
+ // The staged check itself. Split out so `verifySync` owns only the lifetime of
249
+ // the archive it may have opened.
250
+ function inspect(reader: ArchiveReader, io: Source, options: VerifyOptions): VerificationResult {
251
+ const { extraRoots, now = Date.now(), deep = true, trustedRoot, identity, issuer } = options;
252
+ const present = new Map<string, ZLIB.ZipEntry>();
253
+ for (const [name, entry] of reader.entries()) present.set(name, entry);
254
+
255
+ const authority = present.get(AUTHORITY);
256
+ if (!authority) return result('unsigned', 'no manifest entry');
257
+
258
+ const { hashAlg, signAlg, chain } = parseManifest(authority.contentSync());
259
+
260
+ // The signature lives in the EOCD comment as `SIGNED:<hash>:<sig>`; the
261
+ // region the hash covers ends just before the comment's length field.
262
+ const eocd = locateEocd(io.tail(), io.size);
263
+ const marker = parseSignature(eocd.comment.toString('ascii'));
264
+ if (!signAlg || chain.length === 0 || !marker) {
265
+ return result('unsigned', 'manifest carries no signature', chain, { hashAlg });
266
+ }
267
+ const regionEnd = eocd.start + 20; // up to, and excluding, the comment-length field
268
+
269
+ // 1. Integrity (the cheap pre-mount gate): recompute the whole-file hash
270
+ // and confirm it matches the hash recorded in the comment. No certs yet.
271
+ let digest: string | null;
272
+ try {
273
+ const hash = CRYPTO.createHash(hashAlg);
274
+ io.feed(hash, regionEnd);
275
+ digest = hash.digest('hex');
276
+ } catch {
277
+ digest = null;
278
+ }
279
+ if (digest !== marker.hash) return result('invalid', 'archive hash does not match the recorded hash', chain, { hashAlg });
280
+
281
+ // 2. Authenticity: the recorded hash must be signed by the leaf certificate.
282
+ // Because the signature is over the hash, this needs no re-read of the file.
283
+ const leaf = chain[0]!;
284
+ let signatureOk = false;
285
+ try {
286
+ signatureOk = CRYPTO.verify(signAlg, Buffer.from(marker.hash, 'hex'), leaf.publicKey, Buffer.from(marker.sig, 'hex'));
287
+ } catch {
288
+ signatureOk = false;
289
+ }
290
+ if (!signatureOk) return result('invalid', 'signature does not verify against leaf certificate', chain, { hashAlg });
291
+
292
+ // 3. Per-member integrity: every member must record a digest of its own
293
+ // content, and — when `deep` — that digest must match what the member
294
+ // actually decompresses to. (The whole-file hash already fixes every
295
+ // member; this checks each file on its own terms, as a member fetch will.)
296
+ const digests = new Map<string, string>();
297
+ for (const [name, entry] of present) {
298
+ if (name === AUTHORITY || entry.isDirectory) continue;
299
+ const recorded = entry.comment || '';
300
+ if (!/^[0-9a-f]+$/i.test(recorded)) return result('invalid', `member carries no digest: ${name}`, chain, { hashAlg });
301
+ digests.set(name, recorded.toLowerCase());
302
+ if (!deep) continue;
303
+ let memberDigest: string;
304
+ try {
305
+ memberDigest = CRYPTO.createHash(hashAlg).update(entry.contentSync()).digest('hex');
306
+ } catch {
307
+ return result('invalid', `member could not be read: ${name}`, chain, { hashAlg, digests });
308
+ }
309
+ if (memberDigest !== recorded.toLowerCase()) return result('invalid', `digest mismatch: ${name}`, chain, { hashAlg, digests });
310
+ }
311
+
312
+ // 4. Signature and digests are sound; what remains is trust — whether this
313
+ // certificate means anything to us. Which question that is depends on
314
+ // what kind of certificate it is.
315
+ const sigstoreField = marker.fields.get(SIGSTORE.FIELD);
316
+ if (sigstoreField) {
317
+ return sigstoreTrust(sigstoreField, marker, chain, { hashAlg, digests, trustedRoot, identity, issuer });
318
+ }
319
+
320
+ // A demanded identity is a demand about *who signed this*, and only the
321
+ // sigstore path can answer it. An archive signed against an ordinary CA
322
+ // carries no such claim, so the policy cannot be satisfied — and reporting
323
+ // it as trusted anyway would turn `--identity` into a no-op exactly where
324
+ // it is being relied on. Untrusted rather than invalid: the signature is
325
+ // genuine, it just is not the one that was asked for.
326
+ if (identity || issuer) {
327
+ return result('valid-untrusted',
328
+ 'a sigstore identity was required but this archive is not sigstore-signed',
329
+ chain, { hashAlg, digests });
330
+ }
331
+
332
+ const roots = trustRoots(extraRoots);
333
+ const ok = anchored(chain, roots, now);
334
+ return result(ok ? 'valid' : 'valid-untrusted',
335
+ ok ? 'trusted certificate chain' : 'certificate chain not anchored in the trust store',
336
+ chain, { hashAlg, digests });
337
+ }
338
+
339
+ // Trust, for an archive signed through sigstore. This replaces the plain X.509
340
+ // anchoring rather than supplementing it, because the question is different: a
341
+ // Fulcio certificate is valid for about ten minutes, so asking whether it is in
342
+ // date *now* would fail every archive older than lunchtime. What the sigstore
343
+ // bundle carries — a transparency-log entry and an RFC 3161 timestamp — is the
344
+ // evidence needed to ask whether it was in date *when the signature was made*,
345
+ // and `@sigstore/verify` is what checks that end to end: certificate to the
346
+ // Fulcio root, SCT, log inclusion, timestamps, and the signature itself.
347
+ function sigstoreTrust(
348
+ encoded: string,
349
+ marker: SignatureMarker,
350
+ chain: CRYPTO.X509Certificate[],
351
+ { hashAlg, digests, trustedRoot, identity, issuer }: {
352
+ hashAlg: string;
353
+ digests: Map<string, string>;
354
+ trustedRoot?: string | undefined;
355
+ identity?: string | undefined;
356
+ issuer?: string | undefined;
357
+ },
358
+ ): VerificationResult {
359
+ const extra = { hashAlg, digests, sigstore: true };
360
+ const undecided = (reason: string) => result('valid-untrusted', reason, chain, extra);
361
+
362
+ // Not being able to check is not the same answer as checking and finding it
363
+ // forged, so a missing library or trust root degrades rather than fails.
364
+ if (!SIGSTORE.available()) {
365
+ return undecided('archive is sigstore-signed but the sigstore libraries are not installed');
366
+ }
367
+ const root = SIGSTORE.trustedRootSync(trustedRoot);
368
+ if (!root) {
369
+ return undecided('archive is sigstore-signed but no sigstore trust root is available — run `bundle trust`');
370
+ }
371
+
372
+ // The bundle rides in the unhashed comment, so it is the one part of the
373
+ // file an attacker can swap freely. Two checks close that off: the bundle
374
+ // must be over this archive's hash (below, via the artifact argument), and
375
+ // it must name the same certificate as the AUTHORITY.PEM that *is* inside
376
+ // the signed region. Without the second, a valid bundle for someone else's
377
+ // identity could be pinned to an archive whose extractable manifest claims
378
+ // a different signer — the signature would check out and the inspectable
379
+ // file would be a lie.
380
+ let cert: CRYPTO.X509Certificate | null;
381
+ try {
382
+ cert = SIGSTORE.bundleCertificate(encoded);
383
+ } catch (err) {
384
+ return result('invalid', `sigstore bundle could not be read: ${message(err)}`, chain, extra);
385
+ }
386
+ if (!cert) return result('invalid', 'sigstore bundle carries no certificate', chain, extra);
387
+ if (!chain[0] || cert.fingerprint256 !== chain[0].fingerprint256) {
388
+ return result('invalid', 'AUTHORITY.PEM does not name the certificate the sigstore bundle was signed with', chain, extra);
389
+ }
390
+
391
+ try {
392
+ const res = SIGSTORE.verifyBundle(encoded, Buffer.from(marker.hash, 'hex'), { trustedRoot: root, identity, issuer });
393
+ const when = res.signedAt ? `, signed ${res.signedAt.toISOString()}` : '';
394
+ return result('valid', `sigstore identity ${res.identity ?? '(none)'} via ${res.issuer ?? 'unknown issuer'}${when}`,
395
+ chain, { ...extra, identity: res.identity, issuer: res.issuer, signedAt: res.signedAt });
396
+ } catch (err) {
397
+ // A policy failure means the signature is genuine and the signer is
398
+ // simply not the one that was demanded — untrusted, not tampered.
399
+ if (err instanceof Error && err.name === 'PolicyError') {
400
+ return undecided(`sigstore identity does not match the required policy: ${err.message}`);
401
+ }
402
+ return result('invalid', `sigstore verification failed: ${message(err)}`, chain, extra);
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Parse an EOCD comment of the form
408
+ * `SIGNED:<hash-hex>:<signature-hex>[:<NAME>=<value>]*`, or null when the
409
+ * archive is unsigned (no such marker). Field values never contain a `:`, which
410
+ * is what keeps splitting on it unambiguous (base64 does not use one).
411
+ */
412
+ export function parseSignature(comment: string): SignatureMarker | null {
413
+ const m = /^SIGNED:([0-9a-f]+):([0-9a-f]+)((?::[A-Za-z0-9_]+=[^:]*)*)$/.exec(String(comment).trim());
414
+ if (!m) return null;
415
+ const fields = new Map<string, string>();
416
+ for (const part of m[3]!.split(':')) {
417
+ if (!part) continue;
418
+ const eq = part.indexOf('=');
419
+ fields.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
420
+ }
421
+ return { hash: m[1]!.toLowerCase(), sig: m[2]!.toLowerCase(), fields };
422
+ }
423
+
424
+ /**
425
+ * The inverse: render a marker for the EOCD comment. Field order is fixed by
426
+ * insertion, and a field whose value is empty or absent is left out entirely.
427
+ */
428
+ export function formatSignature({ hash, sig, fields }: {
429
+ hash: string;
430
+ sig: string;
431
+ fields?: Record<string, string | undefined | null> | undefined;
432
+ }): string {
433
+ const parts = [`SIGNED:${hash}:${sig}`];
434
+ for (const [name, value] of Object.entries(fields ?? {})) {
435
+ if (value === undefined || value === null || value === '') continue;
436
+ if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`invalid signature field name: ${name}`);
437
+ if (String(value).includes(':')) throw new Error(`signature field '${name}' may not contain ':'`);
438
+ parts.push(`${name.toUpperCase()}=${value}`);
439
+ }
440
+ return parts.join(':');
441
+ }
442
+
443
+ /**
444
+ * The signature marker carried by `source`, or null when it carries none —
445
+ * including when it is not a ZIP at all. Reads only the tail of the file, so it
446
+ * is cheap enough to use as a "does this claim to be one of ours?" test before
447
+ * committing to a full verification.
448
+ */
449
+ export function signatureOf(source: ArchiveSource): SignatureMarker | null {
450
+ try {
451
+ const io = Buffer.isBuffer(source) ? bufferSource(source) : pathSource(source);
452
+ return parseSignature(locateEocd(io.tail(), io.size).comment.toString('ascii'));
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ // Where the bytes come from, abstracted over "a path" and "a Buffer": a
459
+ // statable size, a tail read for the EOCD, a chunked hash feed for the signed
460
+ // region, and an archive opener.
461
+ interface Source {
462
+ size: number;
463
+ tail(): Buffer;
464
+ feed(sink: CRYPTO.Hash, end: number): void;
465
+ open(): ZLIB.ZipFile | ZLIB.ZipBuffer;
466
+ }
467
+
468
+ function pathSource(path: string): Source {
469
+ const size = FS.statSync(path).size;
470
+ return {
471
+ size,
472
+ tail() {
473
+ const len = Math.min(size, 22 + 0xffff);
474
+ const buf = Buffer.alloc(len);
475
+ const fd = FS.openSync(path, 'r');
476
+ try { FS.readSync(fd, buf, 0, len, size - len); } finally { FS.closeSync(fd); }
477
+ return buf;
478
+ },
479
+ feed(sink, end) {
480
+ const fd = FS.openSync(path, 'r');
481
+ try {
482
+ const buf = Buffer.allocUnsafe(Math.min(CHUNK, end));
483
+ let pos = 0;
484
+ while (pos < end) {
485
+ const read = FS.readSync(fd, buf, 0, Math.min(buf.length, end - pos), pos);
486
+ if (read <= 0) throw new Error('unexpected end of file');
487
+ sink.update(buf.subarray(0, read));
488
+ pos += read;
489
+ }
490
+ } finally {
491
+ FS.closeSync(fd);
492
+ }
493
+ },
494
+ open: () => openArchive(path),
495
+ };
496
+ }
497
+
498
+ function bufferSource(buf: Buffer): Source {
499
+ return {
500
+ size: buf.length,
501
+ tail: () => buf.subarray(Math.max(0, buf.length - (22 + 0xffff))),
502
+ feed: (sink, end) => { sink.update(buf.subarray(0, end)); },
503
+ open: () => new ZLIB.ZipBuffer(buf),
504
+ };
505
+ }
506
+
507
+ // Find the end-of-central-directory record in `tail` (the last bytes of a file
508
+ // of total length `size`) and return { start, comment } with `start` absolute.
509
+ // The EOCD must be the last structure in the file, so its comment runs to EOF.
510
+ function locateEocd(tail: Buffer, size: number): { start: number; comment: Buffer } {
511
+ const floor = Math.max(0, tail.length - (22 + 0xffff));
512
+ const scan = (exact: boolean) => {
513
+ for (let pos = tail.length - 22; pos >= floor; pos--) {
514
+ if (tail.readUInt32LE(pos) !== SIG_EOCD) continue;
515
+ const end = pos + 22 + tail.readUInt16LE(pos + 20);
516
+ if (exact ? end !== tail.length : end > tail.length) continue;
517
+ return pos;
518
+ }
519
+ return -1;
520
+ };
521
+ let pos = scan(true);
522
+ if (pos < 0) pos = scan(false);
523
+ if (pos < 0) throw new Error('no end of central directory record found');
524
+ const clen = tail.readUInt16LE(pos + 20);
525
+ return { start: size - tail.length + pos, comment: tail.subarray(pos + 22, pos + 22 + clen) };
526
+ }
527
+
528
+ function openArchive(path: string): ZLIB.ZipFile {
529
+ try {
530
+ return ZLIB.ZipFile.openSync(path);
531
+ } catch (err) {
532
+ // A path that is itself a mount point resolves to the mounted tree
533
+ // rather than to bytes, and opening a directory as a ZIP fails deep
534
+ // inside with a confusing message. (A container's *own* path is not one
535
+ // of these: `--vfs-load` leaves it readable, which is what lets a
536
+ // launcher verify itself. This is for a directory mount, or a mount
537
+ // deliberately placed over an archive.)
538
+ let isDir = false;
539
+ try { isDir = FS.statSync(path).isDirectory(); } catch { /* fall through */ }
540
+ if (isDir) {
541
+ throw new Error(`cannot verify '${path}': it is mounted as a live filesystem ` +
542
+ `(the running container cannot read its own container bytes by path — ` +
543
+ `verify it under a different name)`);
544
+ }
545
+ throw err;
546
+ }
547
+ }
548
+
549
+ function result(
550
+ state: VerificationState,
551
+ reason: string,
552
+ chain?: CRYPTO.X509Certificate[],
553
+ extra?: Partial<VerificationResult>,
554
+ ): VerificationResult {
555
+ return {
556
+ state,
557
+ reason,
558
+ subject: chain && chain[0] ? chain[0].subject : undefined,
559
+ signed: state !== 'unsigned',
560
+ trusted: state === 'valid',
561
+ ...extra,
562
+ };
563
+ }
564
+
565
+ function trustRoots(extra: string[] | undefined): CRYPTO.X509Certificate[] {
566
+ const pems = [...cas('system'), ...cas('extra'), ...(extra || [])];
567
+ return pems.map((pem) => new CRYPTO.X509Certificate(pem));
568
+ }
569
+
570
+ function cas(type: 'system' | 'extra'): string[] {
571
+ try {
572
+ return TLS.getCACertificates(type) || [];
573
+ } catch {
574
+ return [];
575
+ }
576
+ }
577
+
578
+ function within(cert: CRYPTO.X509Certificate, now: number): boolean {
579
+ return Date.parse(cert.validFrom) <= now && now <= Date.parse(cert.validTo);
580
+ }
581
+
582
+ // id-kp-codeSigning (RFC 5280 §4.2.1.12).
583
+ const CODE_SIGNING = '1.3.6.1.5.5.7.3.3';
584
+
585
+ // Path validation: every link in the supplied chain must be issuer-signed and
586
+ // in-date, and the top of the chain must be, or be issued by, a trusted root.
587
+ //
588
+ // Signing alone is not enough to be a link. The roots include the system store,
589
+ // so a chain that only had to be *issued* would let the key of any publicly
590
+ // trusted certificate — a web server's TLS certificate, say — sign an archive
591
+ // that reports as trusted. So the leaf must say it is for signing code, and
592
+ // everything that vouches for it must be a CA.
593
+ function anchored(chain: CRYPTO.X509Certificate[], roots: CRYPTO.X509Certificate[], now: number): boolean {
594
+ const [leaf] = chain;
595
+ if (!leaf || !leaf.keyUsage?.includes(CODE_SIGNING)) return false;
596
+ for (const cert of chain) if (!within(cert, now)) return false;
597
+ for (let i = 0; i < chain.length - 1; i++) {
598
+ if (!chain[i + 1]!.ca) return false;
599
+ if (!chain[i]!.checkIssued(chain[i + 1]!)) return false;
600
+ if (!chain[i]!.verify(chain[i + 1]!.publicKey)) return false;
601
+ }
602
+ const top = chain[chain.length - 1]!;
603
+ for (const root of roots) {
604
+ // A root named directly is trusted as itself, whatever it is: that is
605
+ // pinning, and the caller chose it.
606
+ if (top.fingerprint256 === root.fingerprint256) return true;
607
+ if (root.ca && top.checkIssued(root) && top.verify(root.publicKey) && within(root, now)) return true;
608
+ }
609
+ return false;
610
+ }
611
+
612
+ /** An error's message, for errors that arrive as `unknown`. */
613
+ export function message(err: unknown): string {
614
+ return err instanceof Error ? err.message : String(err);
615
+ }