@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
package/src/oidc.ts ADDED
@@ -0,0 +1,372 @@
1
+ import * as CRYPTO from 'node:crypto';
2
+ import * as HTTP from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ import { once } from 'node:events';
5
+
6
+ // Getting an OIDC identity token to present to Fulcio — the "who are you"
7
+ // half of sigstore signing. Fulcio does not care how the token was obtained,
8
+ // only that it is a valid one from an issuer it recognises, so this module's
9
+ // whole job is to produce one and then get out of the way.
10
+ //
11
+ // Three routes, tried in this order, because the right one is a property of
12
+ // where the command is running rather than something a user should have to
13
+ // pick:
14
+ //
15
+ // * An ambient CI token. GitHub Actions exposes a token endpoint through
16
+ // `ACTIONS_ID_TOKEN_REQUEST_URL`/`ACTIONS_ID_TOKEN_REQUEST_TOKEN` (given
17
+ // `permissions: id-token: write`), and the resulting token names the
18
+ // repository and workflow rather than a person. That is the identity you
19
+ // actually want on a release artifact, so CI wins whenever it is present.
20
+ //
21
+ // * A browser sign-in. Sigstore runs a Dex instance at oauth2.sigstore.dev
22
+ // that federates to GitHub, Google and Microsoft; naming one as
23
+ // `connector_id` skips its chooser and goes straight there. The
24
+ // redirect comes back to a loopback server this process opens on an
25
+ // ephemeral port, which is why no client secret is needed: the flow is a
26
+ // public-client authorization code exchange bound by PKCE.
27
+ //
28
+ // * A device code. The same Dex, for machines with no browser to open — an
29
+ // SSH session, a container. The user is given a short code and a URL to
30
+ // open somewhere else, and this process polls until they finish.
31
+ //
32
+ // Nothing here is sigstore-specific beyond the default endpoints; pointing
33
+ // `issuer` at another OIDC provider works, which is the point of Fulcio
34
+ // accepting a token rather than a credential of its own.
35
+
36
+ export const DEFAULT_ISSUER = 'https://oauth2.sigstore.dev/auth';
37
+ export const DEFAULT_CLIENT_ID = 'sigstore';
38
+ const DEFAULT_SCOPE = 'openid email';
39
+
40
+ /** How an identity token was obtained. */
41
+ export type IdentityFlow = 'supplied' | 'ci' | 'browser' | 'device';
42
+
43
+ /** What the caller may ask for; 'auto' picks by looking at the environment. */
44
+ export type RequestedFlow = 'auto' | 'ci' | 'browser' | 'device';
45
+
46
+ export interface IdentityTokenOptions {
47
+ /** A token supplied by the caller; returned as-is. */
48
+ token?: string | undefined;
49
+ /** OIDC issuer base URL (default: sigstore's Dex). */
50
+ issuer?: string | undefined;
51
+ /** OAuth client id (default: 'sigstore'). */
52
+ clientID?: string | undefined;
53
+ /** Dex connector to jump straight to (default: 'github'). */
54
+ connector?: string | undefined;
55
+ flow?: RequestedFlow | undefined;
56
+ /** Audience to request for the CI token (default: 'sigstore'). */
57
+ audience?: string | undefined;
58
+ log?: ((line: string) => void) | undefined;
59
+ }
60
+
61
+ export interface IdentityTokenResult {
62
+ token: string;
63
+ flow: IdentityFlow;
64
+ }
65
+
66
+ // Dex identifies a connector by the upstream issuer's URL, not by a short name,
67
+ // and rejects the request outright if handed something it does not recognise.
68
+ // These are the three sigstore's instance offers; the short names are a
69
+ // convenience this module translates, so `--connector github` works and an
70
+ // unrecognised value is still passed through verbatim for another deployment.
71
+ const CONNECTORS: Record<string, string> = {
72
+ github: 'https://github.com/login/oauth',
73
+ google: 'https://accounts.google.com',
74
+ microsoft: 'https://login.microsoftonline.com',
75
+ };
76
+
77
+ /**
78
+ * Resolve a connector name to what Dex expects. An empty value means "do not
79
+ * preselect", which lands the user on the provider chooser.
80
+ */
81
+ export function connectorId(name: string | undefined): string | undefined {
82
+ if (!name || name === 'none') return undefined;
83
+ return CONNECTORS[name.toLowerCase()] ?? name;
84
+ }
85
+
86
+ /** The audience Fulcio expects to find in the token it is handed. */
87
+ export const FULCIO_AUDIENCE = 'sigstore';
88
+
89
+ /** Obtain an identity token, by whichever route fits where this is running. */
90
+ export async function identityToken(options: IdentityTokenOptions = {}): Promise<IdentityTokenResult> {
91
+ const opts = {
92
+ issuer: options.issuer ?? DEFAULT_ISSUER,
93
+ clientID: options.clientID ?? DEFAULT_CLIENT_ID,
94
+ connector: connectorId(options.connector ?? 'github'),
95
+ audience: options.audience ?? FULCIO_AUDIENCE,
96
+ log: options.log ?? ((line: string) => { process.stderr.write(`${line}\n`); }),
97
+ };
98
+ const flow = options.flow ?? 'auto';
99
+
100
+ if (options.token) return { token: options.token, flow: 'supplied' };
101
+
102
+ if (flow === 'ci' || (flow === 'auto' && inCI())) {
103
+ if (!inCI()) throw new Error('no CI OIDC token endpoint in the environment');
104
+ return { token: await ciToken(opts.audience), flow: 'ci' };
105
+ }
106
+
107
+ const chosen: 'browser' | 'device' = flow === 'auto' ? (canOpenBrowser() ? 'browser' : 'device') : flow;
108
+ const config = await discover(opts.issuer);
109
+ const token = chosen === 'device' ? await deviceFlow(config, opts) : await browserFlow(config, opts);
110
+ return { token, flow: chosen };
111
+ }
112
+
113
+ /**
114
+ * Whether an ambient CI identity is available. Only GitHub's shape is
115
+ * implemented, since that is what the `--connector github` path mirrors, but
116
+ * this is the hook other providers would land on.
117
+ */
118
+ export function inCI(): boolean {
119
+ return Boolean(process.env['ACTIONS_ID_TOKEN_REQUEST_URL'] && process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']);
120
+ }
121
+
122
+ interface OIDCConfig {
123
+ authorization_endpoint: string;
124
+ token_endpoint: string;
125
+ device_authorization_endpoint?: string;
126
+ }
127
+
128
+ interface ResolvedOptions {
129
+ issuer: string;
130
+ clientID: string;
131
+ connector: string | undefined;
132
+ audience: string;
133
+ log: (line: string) => void;
134
+ }
135
+
136
+ // GitHub Actions' OIDC endpoint. The workflow must ask for it:
137
+ //
138
+ // permissions:
139
+ // id-token: write
140
+ //
141
+ // Without that the variables are simply absent, which is what `inCI()` reads.
142
+ async function ciToken(audience: string): Promise<string> {
143
+ const url = new URL(process.env['ACTIONS_ID_TOKEN_REQUEST_URL']!);
144
+ url.searchParams.set('audience', audience);
145
+ const res = await fetch(url, {
146
+ headers: { Authorization: `Bearer ${process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']}` },
147
+ });
148
+ if (!res.ok) throw new Error(`CI OIDC token request failed: ${res.status} ${res.statusText}`);
149
+ const body = await res.json() as { value?: string };
150
+ if (!body.value) throw new Error('CI OIDC token response carried no token');
151
+ return body.value;
152
+ }
153
+
154
+ // The OIDC discovery document, so endpoints are read from the issuer rather
155
+ // than hard-coded next to it.
156
+ async function discover(issuer: string): Promise<OIDCConfig> {
157
+ const url = `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
158
+ const res = await fetch(url);
159
+ if (!res.ok) throw new Error(`OIDC discovery failed for ${issuer}: ${res.status} ${res.statusText}`);
160
+ return await res.json() as OIDCConfig;
161
+ }
162
+
163
+ // Authorization code + PKCE against a loopback redirect. The server is bound
164
+ // to 127.0.0.1 on an ephemeral port and lives exactly as long as the one
165
+ // request it is waiting for.
166
+ async function browserFlow(config: OIDCConfig, opts: ResolvedOptions): Promise<string> {
167
+ const verifier = base64url(CRYPTO.randomBytes(32));
168
+ const challenge = base64url(CRYPTO.createHash('sha256').update(verifier).digest());
169
+ const state = base64url(CRYPTO.randomBytes(16));
170
+ const nonce = base64url(CRYPTO.randomBytes(16));
171
+
172
+ const { server, port } = await listen();
173
+ const redirect = `http://localhost:${port}/auth/callback`;
174
+ try {
175
+ const authorize = new URL(config.authorization_endpoint);
176
+ authorize.search = new URLSearchParams({
177
+ response_type: 'code',
178
+ client_id: opts.clientID,
179
+ scope: DEFAULT_SCOPE,
180
+ redirect_uri: redirect,
181
+ code_challenge: challenge,
182
+ code_challenge_method: 'S256',
183
+ state,
184
+ nonce,
185
+ ...(opts.connector ? { connector_id: opts.connector } : {}),
186
+ }).toString();
187
+
188
+ opts.log(` opening ${label(opts.connector)}sign-in in your browser`);
189
+ opts.log(` if it does not open, visit:\n ${authorize}`);
190
+ openBrowser(authorize.toString());
191
+
192
+ const code = await awaitCode(server, state);
193
+ return await exchange(config, {
194
+ grant_type: 'authorization_code',
195
+ code,
196
+ redirect_uri: redirect,
197
+ client_id: opts.clientID,
198
+ code_verifier: verifier,
199
+ });
200
+ } finally {
201
+ server.close();
202
+ }
203
+ }
204
+
205
+ // Resolves with the `code` from the single callback request, or rejects with
206
+ // whatever the provider reported instead. `state` is checked before the code is
207
+ // accepted, so a request that did not originate from this flow is rejected.
208
+ function awaitCode(server: HTTP.Server, state: string): Promise<string> {
209
+ return new Promise((resolve, reject) => {
210
+ const timer = setTimeout(() => {
211
+ reject(new Error('timed out waiting for the browser sign-in to complete'));
212
+ }, 5 * 60_000);
213
+ timer.unref?.();
214
+
215
+ server.on('request', (req, res) => {
216
+ const url = new URL(req.url ?? '/', 'http://localhost');
217
+ if (url.pathname !== '/auth/callback') return respond(res, 404, 'Not found.');
218
+ clearTimeout(timer);
219
+
220
+ const error = url.searchParams.get('error');
221
+ if (error) {
222
+ respond(res, 400, `Sign-in failed: ${error}`);
223
+ return reject(new Error(`sign-in failed: ${url.searchParams.get('error_description') || error}`));
224
+ }
225
+ if (url.searchParams.get('state') !== state) {
226
+ respond(res, 400, 'Sign-in failed: state mismatch.');
227
+ return reject(new Error('sign-in failed: state parameter did not match'));
228
+ }
229
+ const code = url.searchParams.get('code');
230
+ if (!code) {
231
+ respond(res, 400, 'Sign-in failed: no authorization code.');
232
+ return reject(new Error('sign-in failed: no authorization code in the callback'));
233
+ }
234
+ respond(res, 200, 'Signed in. You can close this tab and return to the terminal.');
235
+ resolve(code);
236
+ });
237
+ server.on('error', reject);
238
+ });
239
+ }
240
+
241
+ // Device authorization: the user finishes the flow on another device while this
242
+ // process polls. `interval` and the slow_down response are honoured, because
243
+ // Dex will reject a client that ignores them.
244
+ //
245
+ // PKCE is required here as well as on the browser flow — Dex rejects a device
246
+ // request without it — even though the device flow's own security does not
247
+ // depend on it.
248
+ async function deviceFlow(config: OIDCConfig, opts: ResolvedOptions): Promise<string> {
249
+ const endpoint = config.device_authorization_endpoint
250
+ ?? `${opts.issuer.replace(/\/+$/, '')}/device/code`;
251
+ const verifier = base64url(CRYPTO.randomBytes(32));
252
+ const challenge = base64url(CRYPTO.createHash('sha256').update(verifier).digest());
253
+
254
+ const res = await fetch(endpoint, {
255
+ method: 'POST',
256
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
257
+ body: new URLSearchParams({
258
+ client_id: opts.clientID,
259
+ scope: DEFAULT_SCOPE,
260
+ code_challenge: challenge,
261
+ code_challenge_method: 'S256',
262
+ }).toString(),
263
+ });
264
+ if (!res.ok) {
265
+ const detail = await res.text().catch(() => '');
266
+ throw new Error(`device authorization failed: ${res.status} ${detail || res.statusText}`);
267
+ }
268
+ const grant = await res.json() as {
269
+ device_code: string; user_code?: string; verification_uri?: string;
270
+ verification_uri_complete?: string; expires_in?: number; interval?: number;
271
+ };
272
+
273
+ opts.log(` open ${grant.verification_uri_complete ?? grant.verification_uri}`);
274
+ if (grant.user_code) opts.log(` and enter the code: ${grant.user_code}`);
275
+
276
+ const deadline = Date.now() + (grant.expires_in ?? 600) * 1000;
277
+ let interval = (grant.interval ?? 5) * 1000;
278
+ for (;;) {
279
+ await sleep(interval);
280
+ if (Date.now() > deadline) throw new Error('device sign-in expired before it was approved');
281
+ try {
282
+ return await exchange(config, {
283
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
284
+ device_code: grant.device_code,
285
+ client_id: opts.clientID,
286
+ code_verifier: verifier,
287
+ });
288
+ } catch (err) {
289
+ // These two are the flow working as designed: still waiting for the
290
+ // user, or told to back off. Anything else is a real failure.
291
+ const oauthError = (err as { oauthError?: string }).oauthError;
292
+ if (oauthError === 'authorization_pending') continue;
293
+ if (oauthError === 'slow_down') { interval += 5000; continue; }
294
+ throw err;
295
+ }
296
+ }
297
+ }
298
+
299
+ // Token endpoint exchange, shared by both interactive flows. Dex treats
300
+ // `sigstore` as a public client, so the empty client_secret is what it expects
301
+ // rather than an omission.
302
+ async function exchange(config: OIDCConfig, params: Record<string, string>): Promise<string> {
303
+ const res = await fetch(config.token_endpoint, {
304
+ method: 'POST',
305
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
306
+ body: new URLSearchParams({ client_secret: '', ...params }).toString(),
307
+ });
308
+ const body = await res.json().catch(() => ({})) as { id_token?: string; error?: string; error_description?: string };
309
+ if (!res.ok) {
310
+ throw Object.assign(new Error(`token exchange failed: ${body.error_description || body.error || res.statusText}`),
311
+ { oauthError: body.error });
312
+ }
313
+ if (!body.id_token) throw new Error('token exchange returned no id_token');
314
+ return body.id_token;
315
+ }
316
+
317
+ function listen(): Promise<{ server: HTTP.Server; port: number }> {
318
+ const server = HTTP.createServer();
319
+ server.listen(0, '127.0.0.1');
320
+ return once(server, 'listening').then(() => {
321
+ const address = server.address();
322
+ if (!address || typeof address === 'string') throw new Error('loopback server did not bind a port');
323
+ return { server, port: address.port };
324
+ });
325
+ }
326
+
327
+ function respond(res: HTTP.ServerResponse, status: number, message: string): void {
328
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
329
+ res.end(`${message}\n`);
330
+ }
331
+
332
+ // Whether launching a browser is plausible. A headless Linux box has no
333
+ // DISPLAY, and an SSH session should not try to open one on the far end — in
334
+ // both cases the device flow is the honest answer.
335
+ function canOpenBrowser(): boolean {
336
+ if (process.env['BUNDLE_NO_BROWSER']) return false;
337
+ if (process.env['SSH_CONNECTION'] || process.env['SSH_TTY']) return false;
338
+ if (process.platform === 'darwin' || process.platform === 'win32') return true;
339
+ return Boolean(process.env['DISPLAY'] || process.env['WAYLAND_DISPLAY']);
340
+ }
341
+
342
+ // Best effort: if this fails the URL has already been printed, and the flow
343
+ // still completes when the user opens it themselves.
344
+ //
345
+ // Windows goes through the URL protocol handler directly rather than
346
+ // `cmd /c start`: cmd would read every `&` in the query string as a command
347
+ // separator, run the pieces after it, and open a truncated URL.
348
+ function openBrowser(url: string): void {
349
+ const [cmd, ...args] = process.platform === 'darwin' ? ['open', url]
350
+ : process.platform === 'win32' ? ['rundll32', 'url.dll,FileProtocolHandler', url]
351
+ : ['xdg-open', url];
352
+ try {
353
+ spawn(cmd!, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
354
+ } catch {
355
+ // ignored — the URL is on screen
356
+ }
357
+ }
358
+
359
+ function sleep(ms: number): Promise<void> {
360
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
361
+ }
362
+
363
+ function base64url(buf: Buffer): string {
364
+ return buf.toString('base64url');
365
+ }
366
+
367
+ // A connector URL, said the way a person would.
368
+ function label(connector: string | undefined): string {
369
+ if (!connector) return '';
370
+ const name = Object.keys(CONNECTORS).find((key) => CONNECTORS[key] === connector);
371
+ return name ? `${name} ` : '';
372
+ }
package/src/preload.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ // Shared plumbing for the two `-r` preloads.
4
+ //
5
+ // A preload has to load synchronously — `node -r` uses the CommonJS loader, and
6
+ // a module with a top-level `await` in it is rejected outright — so the sibling
7
+ // it pulls in is `require()`d rather than imported. That also puts the load
8
+ // inside a `try`, which is the point: both preloads want a missing `node:vfs`
9
+ // to be said in words rather than raised as a builtin-module error from deep
10
+ // inside node's startup.
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ /**
15
+ * Load a sibling module of `from` by base name, with whichever extension the
16
+ * caller is currently running as — `.ts` straight from source (node strips the
17
+ * types), `.js` once compiled.
18
+ */
19
+ export function sibling<T>(from: string, name: string): T {
20
+ return require(`./${name}${from.endsWith('.ts') ? '.ts' : '.js'}`) as T;
21
+ }
22
+
23
+ /**
24
+ * Run a preload's registration, translating the one failure worth explaining:
25
+ * `node:vfs` only exists under `--experimental-vfs`, and without it every one
26
+ * of these modules is unloadable for a reason the raw error does not make
27
+ * obvious.
28
+ */
29
+ export function preload(register: () => void): void {
30
+ try {
31
+ register();
32
+ } catch (err) {
33
+ const code = (err as { code?: string } | null)?.code;
34
+ const text = err instanceof Error ? err.message : String(err);
35
+ if ((code === 'ERR_UNKNOWN_BUILTIN_MODULE' || code === 'MODULE_NOT_FOUND') && /node:vfs/.test(text)) {
36
+ throw new Error('bundle: node:vfs is unavailable — run node with --experimental-vfs', { cause: err });
37
+ }
38
+ throw err;
39
+ }
40
+ }
@@ -0,0 +1,270 @@
1
+ import * as VFS from 'node:vfs';
2
+ import * as ZLIB from 'node:zlib';
3
+ import * as CRYPTO from 'node:crypto';
4
+ import * as PATH from 'node:path';
5
+ import * as FS from 'node:fs';
6
+ import { AUTHORITY, signatureOf, verifySync, type VerificationResult } from './manifest.ts';
7
+
8
+ // A `node:vfs` file provider for signed archives — `.bundle` files — layered on
9
+ // the built-in `ZipProvider`. It is what turns "this archive is signed" into
10
+ // "this archive is what runs":
11
+ //
12
+ // * At mount time (`open()` below) the whole-file hash is recomputed, the
13
+ // signature over it is checked against the leaf certificate in
14
+ // `AUTHORITY.PEM`, and that chain is anchored in the trust store. An
15
+ // archive that fails any of those never becomes a filesystem at all.
16
+ //
17
+ // * At *fetch* time every member is hashed as it is read and compared with
18
+ // the digest recorded for it in the archive that was verified at mount.
19
+ // The mount-time hash already covers every member's bytes, but it covers
20
+ // them *as they were when the file was hashed* — a `ZipFile` reads members
21
+ // lazily from an open fd, so anything that rewrites the file underneath a
22
+ // running program would otherwise be served unchecked. Verified content is
23
+ // kept (members are an application's own files, not the runtime), so each
24
+ // member is read and hashed at most once and what later reads see is the
25
+ // copy that was verified, not a fresh read of the file.
26
+ //
27
+ // Registering this with `vfs.registerProvider()` puts it ahead of the built-in
28
+ // ZIP provider, so `--vfs-load` hands it the source first. It claims files by
29
+ // extension (`.bundle`) *and* by content — anything carrying our signature
30
+ // marker — so renaming a signed archive cannot quietly downgrade it to the
31
+ // unverified built-in provider.
32
+
33
+ export const EXTENSION = '.bundle';
34
+
35
+ const READ_FLAGS = FS.constants.O_WRONLY | FS.constants.O_RDWR | FS.constants.O_CREAT |
36
+ FS.constants.O_TRUNC | FS.constants.O_APPEND | FS.constants.O_EXCL;
37
+
38
+ export interface ProviderOptions {
39
+ /** File suffixes claimed outright (default: ['.bundle']). */
40
+ extensions?: string[] | undefined;
41
+ /**
42
+ * Also claim any file carrying our signature marker, whatever it is named
43
+ * (default: true). This is what keeps a renamed archive from falling
44
+ * through to the built-in ZIP provider, which checks nothing.
45
+ */
46
+ claimSigned?: boolean | undefined;
47
+ /**
48
+ * Extra trusted roots, as PEM text or paths to PEM files (default: the
49
+ * `BUNDLE_ROOTS` environment variable, a path-delimiter-separated list).
50
+ */
51
+ roots?: string[] | undefined;
52
+ /**
53
+ * Accept a good signature whose chain is not anchored in the trust store
54
+ * (default: the `BUNDLE_ALLOW_UNTRUSTED` environment variable).
55
+ */
56
+ allowUntrusted?: boolean | undefined;
57
+ /**
58
+ * Recompute every member digest at mount instead of on fetch
59
+ * (default: false — fetches check them anyway).
60
+ */
61
+ deep?: boolean | undefined;
62
+ /** Require this sigstore signing identity (default: `BUNDLE_IDENTITY`). */
63
+ identity?: string | undefined;
64
+ /** Require this sigstore OIDC issuer (default: `BUNDLE_ISSUER`). */
65
+ issuer?: string | undefined;
66
+ /** Path to the sigstore trust root (default: `BUNDLE_SIGSTORE_ROOT`). */
67
+ trustedRoot?: string | undefined;
68
+ /** Identifier reported in diagnostics (default: 'bundle'). */
69
+ name?: string | undefined;
70
+ }
71
+
72
+ interface Settings {
73
+ [kSettings]: true;
74
+ name: string;
75
+ extensions: string[];
76
+ claimSigned: boolean;
77
+ extraRoots: string[];
78
+ allowUntrusted: boolean;
79
+ deep: boolean;
80
+ identity: string | undefined;
81
+ issuer: string | undefined;
82
+ trustedRoot: string | undefined;
83
+ }
84
+
85
+ /**
86
+ * Verify `path` and return a provider that serves it, or throw. `options` are
87
+ * the same as `register()`'s.
88
+ */
89
+ export function open(path: string, options?: ProviderOptions | Settings): BundleProvider {
90
+ const opts = settings(options);
91
+ const resolved = PATH.resolve(path);
92
+
93
+ // One ZipFile is opened here and handed to both the verification and the
94
+ // provider: the central directory is read once, and the digests the
95
+ // provider checks against are the ones the verified hash covered.
96
+ const archive = ZLIB.ZipFile.openSync(resolved);
97
+ try {
98
+ const res = verifySync(resolved, {
99
+ archive, deep: opts.deep, extraRoots: opts.extraRoots,
100
+ trustedRoot: opts.trustedRoot, identity: opts.identity, issuer: opts.issuer,
101
+ });
102
+ const acceptable = res.state === 'valid' || (opts.allowUntrusted && res.state === 'valid-untrusted');
103
+ if (!acceptable) throw refusal(resolved, res);
104
+ return new BundleProvider(archive, { hashAlg: res.hashAlg, digests: res.digests });
105
+ } catch (err) {
106
+ archive.closeSync();
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Register this provider with `node:vfs` so the `--vfs-load` startup flag
113
+ * selects it for signed archives. Meant to be preloaded, before `--vfs-load`
114
+ * picks a provider:
115
+ *
116
+ * node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle
117
+ */
118
+ export function register(options?: ProviderOptions): Settings {
119
+ const opts = settings(options);
120
+ VFS.registerProvider({
121
+ name: opts.name,
122
+ canHandle: (resolvedPath, stats) => stats.isFile() && claims(resolvedPath, opts),
123
+ create: (resolvedPath) => open(resolvedPath, opts),
124
+ });
125
+ return opts;
126
+ }
127
+
128
+ /**
129
+ * A read-only `ZipProvider` that will not hand out a member's content until
130
+ * that content has been hashed and matched against the digest recorded for it.
131
+ */
132
+ export class BundleProvider extends VFS.ZipProvider {
133
+ #archive: ZLIB.ZipFile;
134
+ #hashAlg: string;
135
+ #digests: Map<string, string>;
136
+ #verified: VFS.MemoryProvider;
137
+
138
+ /**
139
+ * `digests` is the member-name -> hex-digest map from a `verifySync()` of
140
+ * this very archive; `hashAlg` the algorithm those digests are in.
141
+ */
142
+ constructor(archive: ZLIB.ZipFile, { hashAlg = 'sha256', digests = new Map<string, string>() }: {
143
+ hashAlg?: string | undefined;
144
+ digests?: Map<string, string> | undefined;
145
+ } = {}) {
146
+ super(archive);
147
+ this.#archive = archive;
148
+ this.#hashAlg = hashAlg;
149
+ this.#digests = digests;
150
+ this.#verified = new VFS.MemoryProvider();
151
+
152
+ // The manifest itself carries no digest of its own — it is what names
153
+ // the algorithms — but its bytes were read during the mount-time check,
154
+ // which the whole-file hash covered. Keep that copy so every path the
155
+ // mount serves comes from verified bytes.
156
+ if (archive.has(AUTHORITY)) {
157
+ const entry = archive.getSync(AUTHORITY);
158
+ this.#keep(AUTHORITY, entry.contentSync(), entry.mode);
159
+ }
160
+ }
161
+
162
+ // A signed archive is a fixed artifact: any write would invalidate the
163
+ // signature it was mounted on, so the mount is read-only regardless of how
164
+ // the underlying archive was opened.
165
+ override get readonly(): boolean { return true; }
166
+
167
+ override async open(path: string, flags?: string | number, mode?: number) {
168
+ const name = normalize(path);
169
+ if (reads(flags) && this.#check(name)) return this.#verified.open(path, flags, mode);
170
+ return super.open(path, flags, mode);
171
+ }
172
+
173
+ override openSync(path: string, flags?: string | number, mode?: number) {
174
+ const name = normalize(path);
175
+ if (reads(flags) && this.#check(name)) return this.#verified.openSync(path, flags, mode);
176
+ return super.openSync(path, flags, mode);
177
+ }
178
+
179
+ // Whether `name` can be served from verified content, reading and checking
180
+ // it on first use. `false` means this is not a member with a recorded
181
+ // digest (a directory, or nothing at all) and the base provider should
182
+ // answer — including with the ENOENT/EISDIR it would normally raise.
183
+ #check(name: string): boolean {
184
+ if (this.#verified.existsSync(`/${name}`)) return true;
185
+ const recorded = this.#digests.get(name);
186
+ if (recorded === undefined) return false;
187
+
188
+ const entry = this.#archive.getSync(name);
189
+ const content = entry.contentSync();
190
+ const actual = CRYPTO.createHash(this.#hashAlg).update(content).digest('hex');
191
+ if (actual !== recorded) {
192
+ throw Object.assign(new Error(
193
+ `bundle: content of '${name}' does not match its signed digest ` +
194
+ `(expected ${recorded}, got ${actual})`), { code: 'ERR_BUNDLE_INTEGRITY', member: name });
195
+ }
196
+ this.#keep(name, content, entry.mode);
197
+ return true;
198
+ }
199
+
200
+ #keep(name: string, content: Buffer, mode: number): void {
201
+ const dir = PATH.posix.dirname(`/${name}`);
202
+ if (dir !== '/' && dir !== '.') this.#verified.mkdirSync(dir, { recursive: true });
203
+ this.#verified.writeFileSync(`/${name}`, content, { mode: mode || 0o444 });
204
+ }
205
+ }
206
+
207
+ // Whether this provider should back `resolvedPath` (already known to be a
208
+ // file): by name for the extensions it owns, and by content for anything
209
+ // carrying our signature marker.
210
+ function claims(resolvedPath: string, opts: Settings): boolean {
211
+ const lower = resolvedPath.toLowerCase();
212
+ if (opts.extensions.some((ext) => lower.endsWith(ext))) return true;
213
+ return opts.claimSigned && signatureOf(resolvedPath) !== null;
214
+ }
215
+
216
+ function refusal(path: string, res: VerificationResult): Error {
217
+ const detail = res.subject ? `${res.reason} [${res.subject.replace(/\n/g, ', ')}]` : res.reason;
218
+ return Object.assign(new Error(`bundle: refusing to mount '${path}': ${res.state} — ${detail}`),
219
+ { code: 'ERR_BUNDLE_UNTRUSTED', state: res.state });
220
+ }
221
+
222
+ // VFS paths are normalized to `/`-rooted POSIX paths; ZIP member names
223
+ // have no leading slash.
224
+ function normalize(path: string): string {
225
+ return path.startsWith('/') ? path.slice(1) : path;
226
+ }
227
+
228
+ // Whether `flags` opens purely for reading — the only case verified content can
229
+ // answer. Anything else goes to the base provider, which refuses it (EROFS)
230
+ // because this provider is read-only. Mirrors how `node:fs` itself reads flags:
231
+ // a string means what it says, a number is a bitmask, anything else is 'r'.
232
+ function reads(flags: string | number | undefined): boolean {
233
+ if (typeof flags === 'string') return flags === 'r';
234
+ if (typeof flags !== 'number') return true;
235
+ return (flags & READ_FLAGS) === 0;
236
+ }
237
+
238
+ const kSettings: unique symbol = Symbol('bundle.settings');
239
+
240
+ function settings(options: ProviderOptions | Settings = {}): Settings {
241
+ if ((options as Settings)[kSettings]) return options as Settings;
242
+ const opts = options as ProviderOptions;
243
+ return {
244
+ [kSettings]: true,
245
+ name: opts.name ?? 'bundle',
246
+ extensions: (opts.extensions ?? [EXTENSION]).map((ext) => ext.toLowerCase()),
247
+ claimSigned: opts.claimSigned ?? true,
248
+ extraRoots: pems(opts.roots ?? envList('BUNDLE_ROOTS')),
249
+ allowUntrusted: opts.allowUntrusted ?? envFlag('BUNDLE_ALLOW_UNTRUSTED'),
250
+ deep: opts.deep ?? false,
251
+ identity: opts.identity ?? (process.env['BUNDLE_IDENTITY'] || undefined),
252
+ issuer: opts.issuer ?? (process.env['BUNDLE_ISSUER'] || undefined),
253
+ trustedRoot: opts.trustedRoot ?? (process.env['BUNDLE_SIGSTORE_ROOT'] || undefined),
254
+ };
255
+ }
256
+
257
+ // Accepts roots as PEM text or as paths to PEM files, so a caller can pass
258
+ // either and `BUNDLE_ROOTS` can name files.
259
+ function pems(roots: string[] | undefined): string[] {
260
+ return (roots ?? []).map((root) => (root.includes('-----BEGIN') ? root : FS.readFileSync(root, 'utf-8')));
261
+ }
262
+
263
+ function envList(name: string): string[] {
264
+ return (process.env[name] ?? '').split(PATH.delimiter).filter(Boolean);
265
+ }
266
+
267
+ function envFlag(name: string): boolean {
268
+ const value = process.env[name];
269
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
270
+ }