import * as ZLIB from 'node:zlib'; import * as CRYPTO from 'node:crypto'; import * as TLS from 'node:tls'; import * as SIGSTORE from './sigstore.ts'; import * as ATTEST from './attestation.ts'; import { AUTHORITY, isStructuralError, readerFor, sourceOf, locateEocd, regionHash, memberDigests, parseSignature, parseUnsigned, message, type ArchiveSource, type ArchiveReader, type Source, type SignatureMarker, } from './integrity.ts'; // The integrity layer — the whole-file hash, the recorded markers, member // digests — is integrity.ts, which needs nothing but node's own modules. // Re-exported, since this has always been where the format is. export { AUTHORITY, parseSignature, parseUnsigned, signatureOf, endsInArchive, wholeFileHash, pinOf, checkIntegrity, message, type ArchiveSource, type SignatureMarker, type IntegrityResult, } from './integrity.ts'; // The signature is staged so a verifier can gate cheaply before doing more: // // * Integrity — a single hash covers the *entire file*: the prepended // launcher/binary (when there is one), every member, the whole central // directory (member comments included) and the fixed part of the // end-of-central-directory record, up to but excluding the EOCD's 2-byte // comment-length field. The EOCD must be the last thing in the file, so the // hashed region is simply everything before its trailing comment. // // * Authenticity — the leaf certificate signs that hash (not the file), so // once the hash is known the signature check needs no re-read of the file. // // * Per-member integrity — every member also carries the hex digest of its // own content in its ZIP entry comment, the same guarantee applied one file // at a time. This is what `provider.ts` re-checks on each member fetch, so // content handed to a running program is verified as it is read and not // merely at the moment the archive was mounted. // // Both are recorded in the EOCD comment — which the whole-file hash stops short // of — as a single marker: // // SIGNED::[:=]* // // A verifier can validate the hash on its own (a pre-mount integrity gate), // then check the signature over that hash against the certificate, and only a // signed archive (one carrying this marker) is gated at all. // // The trailing `NAME=value` fields are the unsigned-attribute region this // format needs and every code-signing scheme eventually grows: anything // obtained *after* the signature exists cannot be inside what the signature // covers, so it goes here instead — the same placement RFC 3161 timestamp // tokens get in CMS `unsignedAttrs`. Today the only field is `SIGSTORE=`, a // base64 sigstore bundle carrying the transparency-log entry and timestamp // that establish *when* the archive was signed. Fields are optional and // unknown ones are ignored, so a two-field marker written by an older version // still parses. // // Trust can also come from outside the file: attestations, atproto records in // which someone vouches for the whole-file hash (see attestation.ts). With a // policy that names attesters, the hash is computed and the members checked for // an unsigned archive too, since the attestations are then what authenticates // it; a signature, when present, must still verify, but its certificate only // has to be trusted if `identity`/`issuer` say so. // // The manifest is the `AUTHORITY.PEM` member: it declares the algorithms and // carries the certificate chain (the signing authority) — hence the name, which // is also a real, extractable filename a plain zip utility will happily pull // out when auditing: // // !manifest 2 <- magic + format version // !hash sha256 <- digest for the whole-file hash and members // !sign sha256 <- digest the signature (over that hash) uses // <- blank line (present only when signed) // -----BEGIN CERTIFICATE----- <- full PEM chain, leaf first, embedded so // ... a verifier is self-contained const MAGIC = 'manifest'; const VERSION = '2'; /** What a verification concluded, in increasing order of confidence. */ export type VerificationState = 'unsigned' | 'invalid' | 'valid-untrusted' | 'valid'; /** The archive-side surface of `AUTHORITY.PEM`. */ export interface ManifestFields { /** Format version, absent when the member was not a manifest at all. */ version?: string | undefined; /** Digest used for the whole-file hash and for member digests. */ hashAlg: string; /** Digest the signature over the whole-file hash uses; absent when unsigned. */ signAlg?: string | undefined; /** The certificate chain, leaf first; empty when unsigned. */ chain: CRYPTO.X509Certificate[]; } export interface VerificationResult { state: VerificationState; reason: string; /** Subject of the leaf certificate, when the archive named one. */ subject?: string | undefined; /** True for anything but `unsigned` — the archive claimed a signature. */ signed: boolean; /** True only for `valid`. */ trusted: boolean; hashAlg?: string | undefined; /** Member name -> recorded hex digest, from the archive that was hashed. */ digests?: Map | undefined; /** True when the archive was signed through sigstore. */ sigstore?: boolean | undefined; /** The sigstore signing identity (a SAN), once established. */ identity?: string | undefined; /** The sigstore OIDC issuer, once established. */ issuer?: string | undefined; /** When the signature was witnessed, per the transparency log. */ signedAt?: Date | undefined; /** The whole-file hash, hex, when it was computed. */ hash?: string | undefined; /** What was found for each attester the policy named. */ attestations?: ATTEST.Attestation[] | undefined; /** * SHA-256 fingerprint of the top of the certificate chain — what an X.509 * signer is recognised by, since leaves come and go and the root stays. */ anchor?: string | undefined; /** * True when something the caller explicitly demanded — an identity, an * issuer, attestations — is not there. `allowUntrusted` waives a chain * nobody has anchored; it never waives a demand. */ unmet?: boolean | undefined; } export interface VerifyOptions { /** Additional trusted PEM roots, besides system + NODE_EXTRA_CA_CERTS. */ extraRoots?: string[] | undefined; /** * Reference time for certificate validity (default: now). Ignored on the * sigstore path, which derives the signing time from the archive's own log * entry instead. */ now?: number | undefined; /** * Also recompute every member's content digest (default: true). With * `false` only the presence of the digests is checked; the whole-file hash * already covers the members' bytes, so this is the right trade for a mount * that re-checks each member as it is actually read. */ deep?: boolean | undefined; /** * An already-open archive over `source` to read entries from. When given it * is left open for the caller; otherwise one is opened and closed here. */ archive?: ZLIB.ZipFile | ZLIB.ZipBuffer | undefined; /** * Path to a sigstore trust root, for sigstore-signed archives * (default: BUNDLE_SIGSTORE_ROOT, else the TUF cache, else the seed). */ trustedRoot?: string | undefined; /** Require this sigstore signing identity (SAN). */ identity?: string | undefined; /** Require this sigstore OIDC issuer. */ issuer?: string | undefined; /** Require attestations from these attesters (see attestation.ts). */ attesters?: ATTEST.Attester[] | 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; /** Attesters whose bad verdict refuses the archive, whatever else holds. */ block?: ATTEST.Attester[] | undefined; /** The attestation cache to read (default: `BUNDLE_ATTESTATIONS`, else the state directory). */ attestationCache?: string | undefined; /** * Hash and check every member even of an unsigned archive, so an * `unsigned` answer still means the bytes hold together (default: only * when attestations are required). */ integrity?: boolean | undefined; } /** * How each state is reported, and the exit code that goes with it. * * The codes are part of the CLI's contract — a script branching on * `bundle verify` depends on them — so they live here with the states they * describe rather than in `cli.ts`. That also keeps the `bundle` launcher from * having to load the CLI to find out what exit code a refusal deserves. */ export const STATES: Record = { 'unsigned': { code: 3, label: 'UNSIGNED', note: 'archive carries no signature' }, 'invalid': { code: 2, label: 'INVALID', note: 'manifest is wrong or does not cover the whole archive' }, 'valid-untrusted': { code: 1, label: 'VALID (UNTRUSTED)', note: 'signature is good but the certificate is not trusted' }, 'valid': { code: 0, label: 'VALID', note: 'signature is good and the certificate is trusted' }, }; // Build the manifest content from the algorithms and (when signing) the // certificate chain. export function buildManifest({ hashAlg = 'sha256', signAlg, chain }: { hashAlg?: string | undefined; signAlg?: string | undefined; chain?: string | undefined; } = {}): Buffer { let body = `!${MAGIC} ${VERSION}\n!hash ${hashAlg}\n`; if (signAlg && chain) body += `!sign ${signAlg}\n\n` + String(chain).replace(/\s*$/, '') + '\n'; return Buffer.from(body, 'utf-8'); } // Split manifest bytes into { version, hashAlg, signAlg, chain }. export function parseManifest(content: Buffer | string): ManifestFields { const text = Buffer.isBuffer(content) ? content.toString('utf-8') : String(content); const split = text.indexOf('\n\n'); const head = split >= 0 ? text.slice(0, split) : text; const pem = split >= 0 ? text.slice(split + 2) : ''; const directives: Record = {}; for (const line of head.split('\n')) { if (!line || line[0] !== '!') continue; const sp = line.indexOf(' '); if (sp < 0) directives[line.slice(1)] = ''; else directives[line.slice(1, sp)] = line.slice(sp + 1); } const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || []; return { version: directives[MAGIC], hashAlg: directives['hash'] || 'sha256', signAlg: directives['sign'], chain: blocks.map((block) => new CRYPTO.X509Certificate(block)), }; } /** * Verify an archive. `source` is a filesystem path (opened read-only and read * in chunks) or a Buffer of the whole file. * * `digests` on the result is the member-name -> hex-digest map read from the * entry comments of the very archive that was just hashed. A caller that goes * on to serve those members (see provider.ts) should keep this map rather than * re-reading the comments later, so what it checks content against is what the * signature covered. */ export function verifySync(source: ArchiveSource, options: VerifyOptions = {}): VerificationResult { let reader: ArchiveReader | undefined; try { const io = sourceOf(source); reader = readerFor(options.archive ?? io.open()); return inspect(reader, io, options); } catch (err) { // An archive whose structure no longer holds together has been altered // as surely as one whose hash is wrong, so it gets the same answer. A // file that is missing or unreadable is still an error: there is // nothing there to call invalid. if (isStructuralError(err)) { const reason = (err as { code?: string }).code === 'ERR_BUNDLE_TRAILING_BYTES' ? message(err) : `not a readable ZIP archive: ${message(err)}`; return result('invalid', reason); } throw err; } finally { if (!options.archive) reader?.close(); } } /** * Promise-returning wrapper around `verifySync`, for callers that treat * verification as an asynchronous step. Hashing is CPU-bound either way, so the * work still runs to completion synchronously. */ export async function verify(source: ArchiveSource, options?: VerifyOptions): Promise { return verifySync(source, options); } // The staged check itself. Split out so `verifySync` owns only the lifetime of // the archive it may have opened. function inspect(reader: ArchiveReader, io: Source, options: VerifyOptions): VerificationResult { const { extraRoots, now = Date.now(), deep = true, trustedRoot, identity, issuer } = options; const policy: ATTEST.AttestationPolicy | undefined = options.attesters?.length || options.block?.length ? { attesters: options.attesters ?? [], quorum: options.quorum, maxAge: options.maxAge, block: options.block, cache: options.attestationCache } : undefined; const present = new Map(); for (const [name, entry] of reader.entries()) present.set(name, entry); const authority = present.get(AUTHORITY); if (!authority) return result('unsigned', 'no manifest entry'); const { hashAlg, signAlg, chain } = parseManifest(authority.contentSync()); // The signature lives in the EOCD comment as `SIGNED::` — or, // for an archive not signed yet, the hash alone as `UNSIGNED:`. The // region the hash covers ends just before the comment's length field. const eocd = locateEocd(io.tail(), io.size); const marker = parseSignature(eocd.comment.toString('ascii')); const recorded = marker ? marker.hash : parseUnsigned(eocd.comment.toString('ascii')); const signed = Boolean(signAlg && chain.length > 0 && marker); // An unsigned archive is only worth hashing when attestations could vouch // for it; otherwise "unsigned" is the whole answer. if (!signed && !policy && !options.integrity) { return result('unsigned', 'manifest carries no signature', chain, { hashAlg }); } const regionEnd = eocd.start + 20; // up to, and excluding, the comment-length field // 1. Integrity (the cheap pre-mount gate): recompute the whole-file hash // and confirm it matches the hash recorded in the comment. No certs yet. let digest: string | null; try { digest = regionHash(io, hashAlg, regionEnd); } catch { digest = null; } if (recorded && digest !== recorded) return result('invalid', 'archive hash does not match the recorded hash', chain, { hashAlg }); if (digest === null) return result('invalid', 'archive could not be hashed', chain, { hashAlg }); // 2. Authenticity: the recorded hash must be signed by the leaf certificate. // Because the signature is over the hash, this needs no re-read of the file. if (signed) { const leaf = chain[0]!; let signatureOk = false; try { signatureOk = CRYPTO.verify(signAlg!, Buffer.from(marker!.hash, 'hex'), leaf.publicKey, Buffer.from(marker!.sig, 'hex')); } catch { signatureOk = false; } if (!signatureOk) return result('invalid', 'signature does not verify against leaf certificate', chain, { hashAlg }); } // 3. Per-member integrity: every member must record a digest of its own // content, and — when `deep` — that digest must match what the member // actually decompresses to. (The whole-file hash already fixes every // member; this checks each file on its own terms, as a member fetch will.) const digests = memberDigests(present, hashAlg, deep); if (typeof digests === 'string') return result('invalid', digests, chain, { hashAlg }); // 4. Signature and digests are sound; what remains is trust — whether this // certificate means anything to us, and whether the attesters the policy // names have vouched for these bytes. const base = signed ? signatureTrust(marker!, chain, { hashAlg, digests, digest, trustedRoot, identity, issuer, extraRoots, now }) : undefined; if (!policy) return base ?? result('unsigned', 'manifest carries no signature', chain, { hashAlg, digests, hash: digest }); return attested(base, policy, chain, { hashAlg, digests, digest, signed, identity, issuer, now }); } // Trust in the signature itself. Which question that is depends on what kind // of certificate signed it. function signatureTrust( marker: SignatureMarker, chain: CRYPTO.X509Certificate[], { hashAlg, digests, digest, trustedRoot, identity, issuer, extraRoots, now }: { hashAlg: string; digests: Map; digest: string; trustedRoot?: string | undefined; identity?: string | undefined; issuer?: string | undefined; extraRoots?: string[] | undefined; now: number; }, ): VerificationResult { const sigstoreField = marker.fields.get(SIGSTORE.FIELD); if (sigstoreField) { return sigstoreTrust(sigstoreField, marker, chain, { hashAlg, digests, digest, trustedRoot, identity, issuer }); } // A demanded identity is a demand about *who signed this*, and only the // sigstore path can answer it. An archive signed against an ordinary CA // carries no such claim, so the policy cannot be satisfied — and reporting // it as trusted anyway would turn `--identity` into a no-op exactly where // it is being relied on. Untrusted rather than invalid: the signature is // genuine, it just is not the one that was asked for. if (identity || issuer) { return result('valid-untrusted', 'a sigstore identity was required but this archive is not sigstore-signed', chain, { hashAlg, digests, hash: digest, unmet: true }); } const roots = trustRoots(extraRoots); const ok = anchored(chain, roots, now); return result(ok ? 'valid' : 'valid-untrusted', ok ? 'trusted certificate chain' : 'certificate chain not anchored in the trust store', chain, { hashAlg, digests, hash: digest }); } // Trust when a policy names attesters. The attestations must be there, and the // signature — if the archive has one — must be genuine; whether its // certificate is *trusted* only matters when `identity`/`issuer` demand a // particular signer. That is what lets a policy say "our auditors vouched for // this" without caring who built it, or say both. function attested( base: VerificationResult | undefined, policy: ATTEST.AttestationPolicy, chain: CRYPTO.X509Certificate[], { hashAlg, digests, digest, signed, identity, issuer, now }: { hashAlg: string; digests: Map; digest: string; signed: boolean; identity?: string | undefined; issuer?: string | undefined; now: number; }, ): VerificationResult { if (base?.state === 'invalid') return base; const outcome = ATTEST.evaluate(policy, hashAlg, digest, now); // A blocker's bad verdict is not a missing attestation but a positive claim // that these bytes are harmful, so it answers the question outright. if (outcome.blocked.length) { return result('invalid', outcome.reason, chain, { hashAlg, digests, hash: digest, signed, attestations: [...outcome.attestations, ...outcome.blocked], unmet: true, }); } const signerRequired = Boolean(identity || issuer); const signerOk = !signerRequired || base?.state === 'valid'; const extra: Partial = { hashAlg, digests, hash: digest, signed, attestations: outcome.attestations, sigstore: base?.sigstore, identity: base?.identity, issuer: base?.issuer, signedAt: base?.signedAt, }; if (outcome.met && signerOk) { const reason = signerRequired ? `${base!.reason}; ${outcome.reason}` : outcome.reason; return result('valid', reason, chain, extra); } const missing = [ signerOk ? null : base ? base.reason : 'a sigstore identity was required but this archive is not signed', outcome.met ? null : outcome.reason, ].filter(Boolean).join('; '); // Genuine but not vouched for is untrusted, the same answer as a signature // from someone other than the one demanded; with no signature at all, // nothing vouches for it. return result(signed ? 'valid-untrusted' : 'unsigned', missing, chain, { ...extra, unmet: true }); } // Trust, for an archive signed through sigstore. This replaces the plain X.509 // anchoring rather than supplementing it, because the question is different: a // Fulcio certificate is valid for about ten minutes, so asking whether it is in // date *now* would fail every archive older than lunchtime. What the sigstore // bundle carries — a transparency-log entry and an RFC 3161 timestamp — is the // evidence needed to ask whether it was in date *when the signature was made*, // and `@sigstore/verify` is what checks that end to end: certificate to the // Fulcio root, SCT, log inclusion, timestamps, and the signature itself. function sigstoreTrust( encoded: string, marker: SignatureMarker, chain: CRYPTO.X509Certificate[], { hashAlg, digests, digest, trustedRoot, identity, issuer }: { hashAlg: string; digests: Map; digest: string; trustedRoot?: string | undefined; identity?: string | undefined; issuer?: string | undefined; }, ): VerificationResult { const extra = { hashAlg, digests, sigstore: true, hash: digest }; const undecided = (reason: string) => result('valid-untrusted', reason, chain, extra); // Not being able to check is not the same answer as checking and finding it // forged, so a missing library or trust root degrades rather than fails. if (!SIGSTORE.available()) { return undecided('archive is sigstore-signed but the sigstore libraries are not installed'); } const root = SIGSTORE.trustedRootSync(trustedRoot); if (!root) { return undecided('archive is sigstore-signed but no sigstore trust root is available — run `bundle trust`'); } // The bundle rides in the unhashed comment, so it is the one part of the // file an attacker can swap freely. Two checks close that off: the bundle // must be over this archive's hash (below, via the artifact argument), and // it must name the same certificate as the AUTHORITY.PEM that *is* inside // the signed region. Without the second, a valid bundle for someone else's // identity could be pinned to an archive whose extractable manifest claims // a different signer — the signature would check out and the inspectable // file would be a lie. let cert: CRYPTO.X509Certificate | null; try { cert = SIGSTORE.bundleCertificate(encoded); } catch (err) { return result('invalid', `sigstore bundle could not be read: ${message(err)}`, chain, extra); } if (!cert) return result('invalid', 'sigstore bundle carries no certificate', chain, extra); if (!chain[0] || cert.fingerprint256 !== chain[0].fingerprint256) { return result('invalid', 'AUTHORITY.PEM does not name the certificate the sigstore bundle was signed with', chain, extra); } try { const res = SIGSTORE.verifyBundle(encoded, Buffer.from(marker.hash, 'hex'), { trustedRoot: root, identity, issuer }); const when = res.signedAt ? `, signed ${res.signedAt.toISOString()}` : ''; return result('valid', `sigstore identity ${res.identity ?? '(none)'} via ${res.issuer ?? 'unknown issuer'}${when}`, chain, { ...extra, identity: res.identity, issuer: res.issuer, signedAt: res.signedAt }); } catch (err) { // A policy failure means the signature is genuine and the signer is // simply not the one that was demanded — untrusted, not tampered. if (err instanceof Error && err.name === 'PolicyError') { return result('valid-untrusted', `sigstore identity does not match the required policy: ${err.message}`, chain, { ...extra, unmet: true }); } return result('invalid', `sigstore verification failed: ${message(err)}`, chain, extra); } } /** * The inverse: render a marker for the EOCD comment. Field order is fixed by * insertion, and a field whose value is empty or absent is left out entirely. */ export function formatSignature({ hash, sig, fields }: { hash: string; sig: string; fields?: Record | undefined; }): string { const parts = [`SIGNED:${hash}:${sig}`]; for (const [name, value] of Object.entries(fields ?? {})) { if (value === undefined || value === null || value === '') continue; if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`invalid signature field name: ${name}`); if (String(value).includes(':')) throw new Error(`signature field '${name}' may not contain ':'`); parts.push(`${name.toUpperCase()}=${value}`); } return parts.join(':'); } function result( state: VerificationState, reason: string, chain?: CRYPTO.X509Certificate[], extra?: Partial, ): VerificationResult { return { subject: chain && chain[0] ? chain[0].subject : undefined, anchor: chain && chain.length ? chain[chain.length - 1]!.fingerprint256 : undefined, signed: state !== 'unsigned', ...extra, state, reason, trusted: state === 'valid', }; } function trustRoots(extra: string[] | undefined): CRYPTO.X509Certificate[] { const pems = [...cas('system'), ...cas('extra'), ...(extra || [])]; return pems.map((pem) => new CRYPTO.X509Certificate(pem)); } function cas(type: 'system' | 'extra'): string[] { try { return TLS.getCACertificates(type) || []; } catch { return []; } } function within(cert: CRYPTO.X509Certificate, now: number): boolean { return Date.parse(cert.validFrom) <= now && now <= Date.parse(cert.validTo); } // id-kp-codeSigning (RFC 5280 §4.2.1.12). const CODE_SIGNING = '1.3.6.1.5.5.7.3.3'; // Path validation: every link in the supplied chain must be issuer-signed and // in-date, and the top of the chain must be, or be issued by, a trusted root. // // Signing alone is not enough to be a link. The roots include the system store, // so a chain that only had to be *issued* would let the key of any publicly // trusted certificate — a web server's TLS certificate, say — sign an archive // that reports as trusted. So the leaf must say it is for signing code, and // everything that vouches for it must be a CA. function anchored(chain: CRYPTO.X509Certificate[], roots: CRYPTO.X509Certificate[], now: number): boolean { const [leaf] = chain; if (!leaf || !leaf.keyUsage?.includes(CODE_SIGNING)) return false; for (const cert of chain) if (!within(cert, now)) return false; for (let i = 0; i < chain.length - 1; i++) { if (!chain[i + 1]!.ca) return false; if (!chain[i]!.checkIssued(chain[i + 1]!)) return false; if (!chain[i]!.verify(chain[i + 1]!.publicKey)) return false; } const top = chain[chain.length - 1]!; for (const root of roots) { // A root named directly is trusted as itself, whatever it is: that is // pinning, and the caller chose it. if (top.fingerprint256 === root.fingerprint256) return true; if (root.ca && top.checkIssued(root) && top.verify(root.publicKey) && within(root, now)) return true; } return false; }