import * as VFS from 'node:vfs'; import * as FS from 'node:fs'; import * as PATH from 'node:path'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { isMainThread } from 'node:worker_threads'; import { open as openBundle, settings as providerSettings, type ProviderOptions } from './provider.ts'; import { enforce, carriedFrom } from './plugin-verifier.ts'; import { endsInArchive, message, signatureOf, verifySync, type VerificationResult } from './manifest.ts'; import { attestersFrom, parseDuration } from './attestation.ts'; // Verifying a container, mounting it, and running what is inside — the one path // every shape of this tool ends up taking, factored out of the SEA it used to // live in. // // Three callers reach it, and the differences between them are smaller than // they look: // // * **A self-validating executable.** The archive is appended to the running // binary, so the container is `process.execPath` and the signature covers // the runtime and the verifier along with the application. // * **A verifying node.** The same binary with no archive appended: it takes // one on its command line, checks it, and runs it — // `node-verifying ./my-app.zip --some --app --args`. One runtime, any // number of applications, none of them trusted until they verify. // * **A library.** `run()` does the same thing in a process you already have. // // In every case the mount is the *verifying* provider rather than a plain // `ZipProvider`, so this is not a signature check at startup and nothing more: // every member is re-hashed against its signed digest as it is first read, for // the whole life of the process. /** What a caller can decide about a container before it is allowed to run. */ export interface LaunchOptions { /** Extra trusted roots, as PEM text or paths to PEM files. */ roots?: string[] | undefined; /** Require this sigstore signing identity. */ identity?: string | undefined; /** Require this sigstore OIDC issuer. */ issuer?: string | undefined; /** Path to the sigstore trust root to check against. */ trustedRoot?: string | undefined; /** Require attestations from these attesters, as `[kind@]did`. */ attesters?: string[] | undefined; /** How many of `attesters` must have attested (default: all of them). */ quorum?: number | undefined; /** Milliseconds a cached attestation proof stays good for (default: seven days). */ maxAge?: number | undefined; /** Refuse a container any of these DIDs has marked bad. */ block?: string[] | undefined; /** * Run a container whose signature is good but whose chain is not anchored * in the trust store (default: false). */ allowUntrusted?: boolean | undefined; /** * Recompute every member digest at mount rather than on first read * (default: false — reads check them anyway, and this is startup latency). */ deep?: boolean | undefined; /** Entry point inside the archive, overriding its package.json `main`. */ entry?: string | undefined; /** * What to do when the container does not verify. The default prints the * reason and exits 1; nothing from the archive has run at that point. */ onRefuse?: ((reason: string) => void) | undefined; } /** Where a mounted container ended up, and what it is. */ export interface Mounted { /** The generated mount point the archive is visible at. */ root: string; /** The mount, so a caller can unmount it. */ vfs: VFS.VirtualFileSystem; /** The resolved entry point, as an absolute path under `root`. */ entry: string; } /** * Verify `container` and mount it. Returns where it landed; nothing has been * executed out of it yet. */ export function mount(container: string, options: LaunchOptions = {}): Mounted { const settings: ProviderOptions = { roots: options.roots, identity: options.identity, issuer: options.issuer, trustedRoot: options.trustedRoot, attesters: options.attesters, quorum: options.quorum, maxAge: options.maxAge, block: options.block, allowUntrusted: options.allowUntrusted, deep: options.deep, name: 'bundle-launch', }; let provider; try { provider = openBundle(container, settings); } catch (err) { refuse(options, message(err)); } const vfs = VFS.create(provider, { emitExperimentalWarning: false }); const root = vfs.mount(); // What runs from here may load plugins; they are verified too, with what // carries over from this runtime to code someone else wrote. enforce(carriedFrom(providerSettings(settings))); if (isMainThread && isSea()) remember(container, options, root); return { root, vfs, entry: entryPoint(root, options.entry) }; } /** * Run what is at a mount's entry point. A CommonJS entry is `require()`d and an * ES module is `import()`ed, chosen the way node itself chooses — the archive's * `package.json` `type` and the entry's own extension. */ export async function start({ root, entry }: Mounted): Promise { if (isModule(root, entry)) await import(pathToFileURL(entry).href); else createRequire(PATH.join(root, 'package.json'))(entry); } // -------------------------------------------------------------- workers --- // // A worker thread of a SEA starts with nothing mounted: neither the file // system node gives the SEA's main thread, nor the application the launcher // mounted there. So a worker's own script — a path into that application — // would not resolve, and nothing it loaded would be verified. // // The executable runs a preload in every thread for this (see // `workerPreloadSource()` in sea.ts). In a worker it mounts this package out // of the executable and calls `worker()`, which verifies and mounts what the // main thread did, before the worker's script is loaded. What the main thread // mounted is passed on through the one thing a worker inherits implicitly: a // copy of the environment. /** Where a SEA's main thread records what it mounted, for its workers. */ export const THREAD_ENV = 'BUNDLE_SEA_THREAD'; interface Remembered { container: string; options: LaunchOptions; root: string; } function isSea(): boolean { return Boolean((process.getBuiltinModule('node:sea') as { isSea?: () => boolean } | undefined)?.isSea?.()); } function remember(container: string, options: LaunchOptions, root: string): void { const { onRefuse: _, ...plain } = options; process.env[THREAD_ENV] = JSON.stringify({ container, options: plain, root } satisfies Remembered); } /** * Set up a worker thread of a SEA as its main thread is: the same container, * verified again under the same policy, mounted at the same path — so the * worker's script, a path into it, resolves — with plugins verified as they * are there. * * What the main thread mounted comes from the environment, which the * application itself can change, so it is not taken on trust. A container * with an application appended runs only that application, whatever the * record says; and a sealed runtime's own policy holds over the recorded one, * as it does over a command line. */ export function worker(baked: Baked = {}): Mounted { const text = process.env[THREAD_ENV]; if (!text) { throw new Error(`bundle: this worker cannot tell what its main thread mounted — ${THREAD_ENV} is not in its environment. ` + 'A Worker given an env of its own must carry it over.'); } const recorded = JSON.parse(text) as Remembered; const container = appended(process.execPath) === 'none' ? recorded.container : process.execPath; const options: LaunchOptions = baked.sealed ? { ...recorded.options, ...baked, block: [...new Set([...(baked.block ?? []), ...(recorded.options.block ?? [])])] } : recorded.options; const mounted = mount(container, options); if (mounted.root !== recorded.root) { throw new Error(`bundle: this worker has the application at ${mounted.root}, not at ${recorded.root} where its main thread has it — ` + 'something mounted a file system first'); } return mounted; } /** Verify a container, mount it, and run the application inside. */ export async function run(container: string, options: LaunchOptions = {}): Promise { await start(mount(container, options)); } /** * Verify the running executable and run the archive appended to it — the * self-validating shape, where the signature covers the runtime, the verifier * and the application as one file. */ export async function runSelf(options: LaunchOptions = {}): Promise { await run(process.execPath, options); } /** Verify a container without mounting or running it. */ export function verify(container: string, options: LaunchOptions = {}): VerificationResult { const roots = (options.roots ?? []).map((root) => (root.includes('-----BEGIN') ? root : FS.readFileSync(root, 'utf-8'))); return verifySync(container, { extraRoots: roots, deep: options.deep ?? false, identity: options.identity, issuer: options.issuer, trustedRoot: options.trustedRoot, attesters: options.attesters ? attestersFrom(options.attesters) : undefined, quorum: options.quorum, maxAge: options.maxAge, block: options.block ? attestersFrom(options.block) : undefined, }); } /** * What, if anything, is appended to a container. * * This is how one binary can be both shapes: an executable with a signed * archive behind it runs *that*, and the same executable with nothing appended * takes an archive from its command line instead. The discriminator is the * signature marker rather than the presence of a ZIP, because a SEA carries an * archive of its own inside its blob — one that is not at the tail, and never * carries a marker. */ export function appended(container: string): 'signed' | 'unsigned' | 'none' { if (signatureOf(container) !== null) return 'signed'; // An archive at the tail with no marker is the one case worth naming: // somebody appended an application and never signed it. "At the tail" // means ending exactly at the end of the file: a SEA's own file system is // an archive too, inside its blob, and a ZIP reader that looks back from // the end can find it — but it has the rest of the executable after it. return endsInArchive(container) ? 'unsigned' : 'none'; } // --------------------------------------------------------- the command line --- /** * The command line of a verifying node. * * Options are read up to the first non-option argument, which is the archive; * everything after it belongs to the application, untouched. That is the shape * of every command that wraps another one — `env`, `nice`, `time` — and it is * what lets an application have flags of its own that collide with these. */ export const USAGE = `usage: [options] [args...] Verifies an archive and runs the application inside it. Nothing from the archive runs until its signature, its certificate chain and its member digests check out, and every member is re-hashed as it is read for the life of the process. options: -r, --root extra trusted root certificate (PEM); repeatable --identity require this sigstore signing identity --issuer require this sigstore OIDC issuer --attester <[kind@]did> require an attestation from this DID; repeatable. Checked against the cache 'bundle trust' fills --quorum how many of the attesters must have attested (default: all) --max-age