import * as VFS from 'node:vfs'; import * as ZLIB from 'node:zlib'; import * as PATH from 'node:path'; import * as FS from 'node:fs'; import { signatureOf, verifySync, type VerificationResult } from './manifest.ts'; import { attestersFrom, policyFromEnvironment, type Attester } from './attestation.ts'; import { BundleProvider } from './bundle-provider.ts'; export { BundleProvider }; // A `node:vfs` file provider for signed archives — `.nzip` files — layered on // the built-in `ZipProvider`. It is what turns "this archive is signed" into // "this archive is what runs": // // * At mount time (`open()` below) the whole-file hash is recomputed, the // signature over it is checked against the leaf certificate in // `AUTHORITY.PEM`, and that chain is anchored in the trust store — or, // under a policy that names attesters, the attestations cached for them // vouch for the hash (see attestation.ts). An archive that fails any of // those never becomes a filesystem at all. // // * At *fetch* time every member is hashed as it is read and compared with // the digest recorded for it in the archive that was verified at mount. // The mount-time hash already covers every member's bytes, but it covers // them *as they were when the file was hashed* — a `ZipFile` reads members // lazily from an open fd, so anything that rewrites the file underneath a // running program would otherwise be served unchecked. Verified content is // kept (members are an application's own files, not the runtime), so each // member is read and hashed at most once and what later reads see is the // copy that was verified, not a fresh read of the file. // // Registering this with `vfs.registerProvider()` puts it ahead of the built-in // ZIP provider, so `--vfs-load` hands it the source first — and it claims every // archive it is offered, not only the ones it can serve. That is the point: with // this provider registered, a ZIP either verifies or does not mount. There is no // third outcome where it quietly falls through to the built-in provider, which // checks nothing. // // So what a file is called changes nothing. A `.nzip` is a bundle, signed or // not: a signed one mounts when its signature is good, and an unsigned one only // when the attestations the policy requires vouch for it. Any other ZIP is // claimed too, on the same terms. The only way to run an unsigned archive that // nobody vouches for is with this provider *not* in the mix — plain // `--vfs-load`, no preload. export const EXTENSION = '.nzip'; export interface ProviderOptions { /** File suffixes claimed outright (default: ['.nzip']). */ extensions?: string[] | undefined; /** * Also claim any file carrying our signature marker, whatever it is named * (default: true). This is what keeps a renamed archive from falling * through to the built-in ZIP provider, which checks nothing. */ claimSigned?: boolean | undefined; /** * Also claim any file that is a ZIP at all, whatever it is called, and * refuse it for want of a signature unless attestations vouch for it * (default: true). * * Without this, an unsigned archive mounts *unverified* through the * built-in provider while this one is registered, which reads as a pass. * With it, registering this provider means exactly one thing: nothing * mounts unless it verifies. */ claimArchives?: boolean | undefined; /** * Extra trusted roots, as PEM text or paths to PEM files (default: the * `BUNDLE_ROOTS` environment variable, a path-delimiter-separated list). */ roots?: string[] | undefined; /** * Accept a good signature whose chain is not anchored in the trust store * (default: the `BUNDLE_ALLOW_UNTRUSTED` environment variable). */ allowUntrusted?: boolean | undefined; /** * Recompute every member digest at mount instead of on fetch * (default: false — fetches check them anyway). */ deep?: boolean | undefined; /** Require this sigstore signing identity (default: `BUNDLE_IDENTITY`). */ identity?: string | undefined; /** Require this sigstore OIDC issuer (default: `BUNDLE_ISSUER`). */ issuer?: string | undefined; /** Path to the sigstore trust root (default: `BUNDLE_SIGSTORE_ROOT`). */ trustedRoot?: string | undefined; /** * Require attestations from these attesters, as `[kind@]did` (default: * `BUNDLE_ATTESTERS`). Checked against the attestation cache only — a * mount does not reach for the network. */ attesters?: string[] | undefined; /** How many of `attesters` must have attested (default: `BUNDLE_QUORUM`, else all). */ quorum?: number | undefined; /** DIDs whose bad verdict refuses an archive (default: `BUNDLE_BLOCK`). */ block?: string[] | undefined; /** Milliseconds a cached proof stays good for (default: `BUNDLE_ATTESTATION_MAX_AGE`, else seven days). */ maxAge?: number | undefined; /** Identifier reported in diagnostics (default: 'bundle'). */ name?: string | undefined; } export interface Settings { [kSettings]: true; name: string; extensions: string[]; claimSigned: boolean; claimArchives: boolean; extraRoots: string[]; allowUntrusted: boolean; deep: boolean; identity: string | undefined; issuer: string | undefined; trustedRoot: string | undefined; attesters: Attester[]; quorum: number | undefined; maxAge: number | undefined; block: Attester[]; } /** * Verify `path` and return a provider that serves it, or throw. `options` are * the same as `register()`'s. */ export function open(path: string, options?: ProviderOptions | Settings): BundleProvider { const opts = settings(options); const resolved = PATH.resolve(path); // One ZipFile is opened here and handed to both the verification and the // provider: the central directory is read once, and the digests the // provider checks against are the ones the verified hash covered. const archive = ZLIB.ZipFile.openSync(resolved); try { const res = verifySync(resolved, { archive, deep: opts.deep, extraRoots: opts.extraRoots, trustedRoot: opts.trustedRoot, identity: opts.identity, issuer: opts.issuer, attesters: opts.attesters, quorum: opts.quorum, maxAge: opts.maxAge, block: opts.block, }); // `allowUntrusted` is about anchoring, never about a demand: an identity // or attesters that were asked for and are not there still refuse. const acceptable = res.state === 'valid' || (opts.allowUntrusted && res.state === 'valid-untrusted' && !res.unmet); if (!acceptable) throw refusal(resolved, res); return new BundleProvider(archive, { hashAlg: res.hashAlg, digests: res.digests }); } catch (err) { archive.closeSync(); throw err; } } /** * Register this provider with `node:vfs` so the `--vfs-load` startup flag * selects it for signed archives. Meant to be preloaded, before `--vfs-load` * picks a provider: * * node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.nzip */ export function register(options?: ProviderOptions): Settings { const opts = settings(options); VFS.registerProvider({ name: opts.name, canHandle: (resolvedPath, stats) => stats.isFile() && claims(resolvedPath, opts), create: (resolvedPath) => open(resolvedPath, opts), }); return opts; } // Whether this provider should back `resolvedPath` (already known to be a // file): by name for the extensions it owns, and by content for anything // carrying our signature marker. function claims(resolvedPath: string, opts: Settings): boolean { const lower = resolvedPath.toLowerCase(); if (opts.extensions.some((ext) => lower.endsWith(ext))) return true; if (opts.claimSigned && signatureOf(resolvedPath) !== null) return true; return opts.claimArchives && isArchive(resolvedPath); } // Whether the file is a ZIP at all, by opening it as one — which reads the // central directory and so also covers an archive behind a prefix, where the // first bytes are a launcher rather than `PK`. function isArchive(resolvedPath: string): boolean { try { ZLIB.ZipFile.openSync(resolvedPath).closeSync(); return true; } catch { return false; } } function refusal(path: string, res: VerificationResult): Error { const detail = res.subject ? `${res.reason} [${res.subject.replace(/\n/g, ', ')}]` : res.reason; return Object.assign(new Error(`bundle: refusing to mount '${path}': ${res.state} — ${detail}`), { code: 'ERR_BUNDLE_UNTRUSTED', state: res.state }); } const kSettings: unique symbol = Symbol('bundle.settings'); /** `options` with every default filled in from the environment, as `open()` and `register()` use them. */ export function settings(options: ProviderOptions | Settings = {}): Settings { if ((options as Settings)[kSettings]) return options as Settings; const opts = options as ProviderOptions; const environment = policyFromEnvironment(); return { [kSettings]: true, name: opts.name ?? 'bundle', extensions: (opts.extensions ?? [EXTENSION]).map((ext) => ext.toLowerCase()), claimSigned: opts.claimSigned ?? true, claimArchives: opts.claimArchives ?? true, extraRoots: pems(opts.roots ?? envList('BUNDLE_ROOTS')), allowUntrusted: opts.allowUntrusted ?? envFlag('BUNDLE_ALLOW_UNTRUSTED'), deep: opts.deep ?? false, identity: opts.identity ?? (process.env['BUNDLE_IDENTITY'] || undefined), issuer: opts.issuer ?? (process.env['BUNDLE_ISSUER'] || undefined), trustedRoot: opts.trustedRoot ?? (process.env['BUNDLE_SIGSTORE_ROOT'] || undefined), attesters: opts.attesters ? attestersFrom(opts.attesters) : environment.attesters, quorum: opts.quorum ?? environment.quorum, maxAge: opts.maxAge ?? environment.maxAge, block: opts.block ? attestersFrom(opts.block) : environment.block ?? [], }; } // Accepts roots as PEM text or as paths to PEM files, so a caller can pass // either and `BUNDLE_ROOTS` can name files. function pems(roots: string[] | undefined): string[] { return (roots ?? []).map((root) => (root.includes('-----BEGIN') ? root : FS.readFileSync(root, 'utf-8'))); } function envList(name: string): string[] { return (process.env[name] ?? '').split(PATH.delimiter).filter(Boolean); } function envFlag(name: string): boolean { const value = process.env[name]; return value !== undefined && value !== '' && value !== '0' && value !== 'false'; }