@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.
- package/HISTORY.md +1924 -0
- package/README.md +623 -0
- package/bundle.run +0 -0
- package/dist/api.d.ts +147 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +174 -0
- package/dist/api.js.map +1 -0
- package/dist/archive.d.ts +115 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +188 -0
- package/dist/archive.js.map +1 -0
- package/dist/audit.d.ts +78 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +119 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +555 -0
- package/dist/cli.js.map +1 -0
- package/dist/files.d.ts +53 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +118 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +97 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +267 -0
- package/dist/launch.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +19 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest.d.ts +139 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +504 -0
- package/dist/manifest.js.map +1 -0
- package/dist/oidc.d.ts +40 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +320 -0
- package/dist/oidc.js.map +1 -0
- package/dist/preload.d.ts +14 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +38 -0
- package/dist/preload.js.map +1 -0
- package/dist/provider.d.ts +83 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +206 -0
- package/dist/provider.js.map +1 -0
- package/dist/record.d.ts +2 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +23 -0
- package/dist/record.js.map +1 -0
- package/dist/recorder.d.ts +64 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +111 -0
- package/dist/recorder.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +28 -0
- package/dist/register.js.map +1 -0
- package/dist/sea.d.ts +97 -0
- package/dist/sea.d.ts.map +1 -0
- package/dist/sea.js +220 -0
- package/dist/sea.js.map +1 -0
- package/dist/sigstore.d.ts +112 -0
- package/dist/sigstore.d.ts.map +1 -0
- package/dist/sigstore.js +385 -0
- package/dist/sigstore.js.map +1 -0
- package/dist/skill.d.ts +36 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +108 -0
- package/dist/skill.js.map +1 -0
- package/package.json +84 -0
- package/shell-base +2 -0
- package/skills/audit-bundle/SKILL.md +271 -0
- package/src/api.ts +293 -0
- package/src/archive.ts +312 -0
- package/src/audit.ts +206 -0
- package/src/cli.ts +575 -0
- package/src/files.ts +156 -0
- package/src/index.ts +114 -0
- package/src/launch.ts +336 -0
- package/src/main.ts +20 -0
- package/src/manifest.ts +615 -0
- package/src/oidc.ts +372 -0
- package/src/preload.ts +40 -0
- package/src/provider.ts +270 -0
- package/src/record.ts +25 -0
- package/src/recorder.ts +166 -0
- package/src/register.ts +30 -0
- package/src/sea.ts +341 -0
- package/src/sigstore.ts +492 -0
- package/src/skill.ts +132 -0
- package/src/types/node-vfs.d.ts +90 -0
- package/src/types/node-zip.d.ts +85 -0
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|