@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
@@ -0,0 +1,139 @@
1
+ import * as ZLIB from 'node:zlib';
2
+ import * as CRYPTO from 'node:crypto';
3
+ export declare const AUTHORITY = "AUTHORITY.PEM";
4
+ /** What a verification concluded, in increasing order of confidence. */
5
+ export type VerificationState = 'unsigned' | 'invalid' | 'valid-untrusted' | 'valid';
6
+ /** The archive-side surface of `AUTHORITY.PEM`. */
7
+ export interface ManifestFields {
8
+ /** Format version, absent when the member was not a manifest at all. */
9
+ version?: string | undefined;
10
+ /** Digest used for the whole-file hash and for member digests. */
11
+ hashAlg: string;
12
+ /** Digest the signature over the whole-file hash uses; absent when unsigned. */
13
+ signAlg?: string | undefined;
14
+ /** The certificate chain, leaf first; empty when unsigned. */
15
+ chain: CRYPTO.X509Certificate[];
16
+ }
17
+ /** A parsed `SIGNED:<hash>:<sig>[:<NAME>=<value>]*` marker. */
18
+ export interface SignatureMarker {
19
+ hash: string;
20
+ sig: string;
21
+ /** The trailing unsigned attributes, keyed by upper-case name. */
22
+ fields: Map<string, string>;
23
+ }
24
+ export interface VerificationResult {
25
+ state: VerificationState;
26
+ reason: string;
27
+ /** Subject of the leaf certificate, when the archive named one. */
28
+ subject?: string | undefined;
29
+ /** True for anything but `unsigned` — the archive claimed a signature. */
30
+ signed: boolean;
31
+ /** True only for `valid`. */
32
+ trusted: boolean;
33
+ hashAlg?: string | undefined;
34
+ /** Member name -> recorded hex digest, from the archive that was hashed. */
35
+ digests?: Map<string, string> | undefined;
36
+ /** True when the archive was signed through sigstore. */
37
+ sigstore?: boolean | undefined;
38
+ /** The sigstore signing identity (a SAN), once established. */
39
+ identity?: string | undefined;
40
+ /** The sigstore OIDC issuer, once established. */
41
+ issuer?: string | undefined;
42
+ /** When the signature was witnessed, per the transparency log. */
43
+ signedAt?: Date | undefined;
44
+ }
45
+ export interface VerifyOptions {
46
+ /** Additional trusted PEM roots, besides system + NODE_EXTRA_CA_CERTS. */
47
+ extraRoots?: string[] | undefined;
48
+ /**
49
+ * Reference time for certificate validity (default: now). Ignored on the
50
+ * sigstore path, which derives the signing time from the archive's own log
51
+ * entry instead.
52
+ */
53
+ now?: number | undefined;
54
+ /**
55
+ * Also recompute every member's content digest (default: true). With
56
+ * `false` only the presence of the digests is checked; the whole-file hash
57
+ * already covers the members' bytes, so this is the right trade for a mount
58
+ * that re-checks each member as it is actually read.
59
+ */
60
+ deep?: boolean | undefined;
61
+ /**
62
+ * An already-open archive over `source` to read entries from. When given it
63
+ * is left open for the caller; otherwise one is opened and closed here.
64
+ */
65
+ archive?: ZLIB.ZipFile | ZLIB.ZipBuffer | undefined;
66
+ /**
67
+ * Path to a sigstore trust root, for sigstore-signed archives
68
+ * (default: BUNDLE_SIGSTORE_ROOT, else the TUF cache, else the seed).
69
+ */
70
+ trustedRoot?: string | undefined;
71
+ /** Require this sigstore signing identity (SAN). */
72
+ identity?: string | undefined;
73
+ /** Require this sigstore OIDC issuer. */
74
+ issuer?: string | undefined;
75
+ }
76
+ /** An archive to be built or verified: a path on disk, or the bytes themselves. */
77
+ export type ArchiveSource = string | Buffer;
78
+ /**
79
+ * How each state is reported, and the exit code that goes with it.
80
+ *
81
+ * The codes are part of the CLI's contract — a script branching on
82
+ * `bundle verify` depends on them — so they live here with the states they
83
+ * describe rather than in `cli.ts`. That also keeps the `bundle` launcher from
84
+ * having to load the CLI to find out what exit code a refusal deserves.
85
+ */
86
+ export declare const STATES: Record<VerificationState, {
87
+ code: number;
88
+ label: string;
89
+ note: string;
90
+ }>;
91
+ export declare function buildManifest({ hashAlg, signAlg, chain }?: {
92
+ hashAlg?: string | undefined;
93
+ signAlg?: string | undefined;
94
+ chain?: string | undefined;
95
+ }): Buffer;
96
+ export declare function parseManifest(content: Buffer | string): ManifestFields;
97
+ /**
98
+ * Verify an archive. `source` is a filesystem path (opened read-only and read
99
+ * in chunks) or a Buffer of the whole file.
100
+ *
101
+ * `digests` on the result is the member-name -> hex-digest map read from the
102
+ * entry comments of the very archive that was just hashed. A caller that goes
103
+ * on to serve those members (see provider.ts) should keep this map rather than
104
+ * re-reading the comments later, so what it checks content against is what the
105
+ * signature covered.
106
+ */
107
+ export declare function verifySync(source: ArchiveSource, options?: VerifyOptions): VerificationResult;
108
+ /**
109
+ * Promise-returning wrapper around `verifySync`, for callers that treat
110
+ * verification as an asynchronous step. Hashing is CPU-bound either way, so the
111
+ * work still runs to completion synchronously.
112
+ */
113
+ export declare function verify(source: ArchiveSource, options?: VerifyOptions): Promise<VerificationResult>;
114
+ /**
115
+ * Parse an EOCD comment of the form
116
+ * `SIGNED:<hash-hex>:<signature-hex>[:<NAME>=<value>]*`, or null when the
117
+ * archive is unsigned (no such marker). Field values never contain a `:`, which
118
+ * is what keeps splitting on it unambiguous (base64 does not use one).
119
+ */
120
+ export declare function parseSignature(comment: string): SignatureMarker | null;
121
+ /**
122
+ * The inverse: render a marker for the EOCD comment. Field order is fixed by
123
+ * insertion, and a field whose value is empty or absent is left out entirely.
124
+ */
125
+ export declare function formatSignature({ hash, sig, fields }: {
126
+ hash: string;
127
+ sig: string;
128
+ fields?: Record<string, string | undefined | null> | undefined;
129
+ }): string;
130
+ /**
131
+ * The signature marker carried by `source`, or null when it carries none —
132
+ * including when it is not a ZIP at all. Reads only the tail of the file, so it
133
+ * is cheap enough to use as a "does this claim to be one of ours?" test before
134
+ * committing to a full verification.
135
+ */
136
+ export declare function signatureOf(source: ArchiveSource): SignatureMarker | null;
137
+ /** An error's message, for errors that arrive as `unknown`. */
138
+ export declare function message(err: unknown): string;
139
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAwDtC,eAAO,MAAM,SAAS,kBAAkB,CAAC;AAIzC,wEAAwE;AACxE,MAAM,MAAM,iBAAiB,GAAG,UAAU,GAAG,SAAS,GAAG,iBAAiB,GAAG,OAAO,CAAC;AAErF,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC3B,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC,eAAe,EAAE,CAAC;CACnC;AAED,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,kEAAkE;IAClE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IAC/B,KAAK,EAAE,iBAAiB,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,0EAA0E;IAC1E,MAAM,EAAE,OAAO,CAAC;IAChB,6BAA6B;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAC1C,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,kDAAkD;IAClD,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,aAAa;IAC1B,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAClC;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B;;;OAGG;IACH,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IACpD;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/B;AAED,mFAAmF;AACnF,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C;;;;;;;GAOG;AACH,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,iBAAiB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAK3F,CAAC;AAIF,wBAAgB,aAAa,CAAC,EAAE,OAAkB,EAAE,OAAO,EAAE,KAAK,EAAE,GAAE;IAClE,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACzB,GAAG,MAAM,CAId;AAGD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,cAAc,CAmBtE;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CAgBjG;AAOD;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAExG;AA+KD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAUtE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;CAClE,GAAG,MAAM,CAST;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,aAAa,GAAG,eAAe,GAAG,IAAI,CAOzE;AA4JD,+DAA+D;AAC/D,wBAAgB,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAE5C"}
@@ -0,0 +1,504 @@
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.js';
6
+ // The signature is staged so a verifier can gate cheaply before doing more:
7
+ //
8
+ // * Integrity — a single hash covers the *entire file*: the prepended
9
+ // launcher/binary (when there is one), every member, the whole central
10
+ // directory (member comments included) and the fixed part of the
11
+ // end-of-central-directory record, up to but excluding the EOCD's 2-byte
12
+ // comment-length field. The EOCD must be the last thing in the file, so the
13
+ // hashed region is simply everything before its trailing comment.
14
+ //
15
+ // * Authenticity — the leaf certificate signs that hash (not the file), so
16
+ // once the hash is known the signature check needs no re-read of the file.
17
+ //
18
+ // * Per-member integrity — every member also carries the hex digest of its
19
+ // own content in its ZIP entry comment, the same guarantee applied one file
20
+ // at a time. This is what `provider.ts` re-checks on each member fetch, so
21
+ // content handed to a running program is verified as it is read and not
22
+ // merely at the moment the archive was mounted.
23
+ //
24
+ // Both are recorded in the EOCD comment — which the whole-file hash stops short
25
+ // of — as a single marker:
26
+ //
27
+ // SIGNED:<hash-of-region-hex>:<signature-hex>[:<NAME>=<value>]*
28
+ //
29
+ // A verifier can validate the hash on its own (a pre-mount integrity gate),
30
+ // then check the signature over that hash against the certificate, and only a
31
+ // signed archive (one carrying this marker) is gated at all.
32
+ //
33
+ // The trailing `NAME=value` fields are the unsigned-attribute region this
34
+ // format needs and every code-signing scheme eventually grows: anything
35
+ // obtained *after* the signature exists cannot be inside what the signature
36
+ // covers, so it goes here instead — the same placement RFC 3161 timestamp
37
+ // tokens get in CMS `unsignedAttrs`. Today the only field is `SIGSTORE=`, a
38
+ // base64 sigstore bundle carrying the transparency-log entry and timestamp
39
+ // that establish *when* the archive was signed. Fields are optional and
40
+ // unknown ones are ignored, so a two-field marker written by an older version
41
+ // still parses.
42
+ //
43
+ // The manifest is the `AUTHORITY.PEM` member: it declares the algorithms and
44
+ // carries the certificate chain (the signing authority) — hence the name, which
45
+ // is also a real, extractable filename a plain zip utility will happily pull
46
+ // out when auditing:
47
+ //
48
+ // !manifest 2 <- magic + format version
49
+ // !hash sha256 <- digest for the whole-file hash and members
50
+ // !sign sha256 <- digest the signature (over that hash) uses
51
+ // <- blank line (present only when signed)
52
+ // -----BEGIN CERTIFICATE----- <- full PEM chain, leaf first, embedded so
53
+ // ... a verifier is self-contained
54
+ const MAGIC = 'manifest';
55
+ const VERSION = '2';
56
+ export const AUTHORITY = 'AUTHORITY.PEM';
57
+ const SIG_EOCD = 0x06054b50;
58
+ const CHUNK = 1 << 20;
59
+ /**
60
+ * How each state is reported, and the exit code that goes with it.
61
+ *
62
+ * The codes are part of the CLI's contract — a script branching on
63
+ * `bundle verify` depends on them — so they live here with the states they
64
+ * describe rather than in `cli.ts`. That also keeps the `bundle` launcher from
65
+ * having to load the CLI to find out what exit code a refusal deserves.
66
+ */
67
+ export const STATES = {
68
+ 'unsigned': { code: 3, label: 'UNSIGNED', note: 'archive carries no signature' },
69
+ 'invalid': { code: 2, label: 'INVALID', note: 'manifest is wrong or does not cover the whole archive' },
70
+ 'valid-untrusted': { code: 1, label: 'VALID (UNTRUSTED)', note: 'signature is good but the certificate is not trusted' },
71
+ 'valid': { code: 0, label: 'VALID', note: 'signature is good and the certificate is trusted' },
72
+ };
73
+ // Build the manifest content from the algorithms and (when signing) the
74
+ // certificate chain.
75
+ export function buildManifest({ hashAlg = 'sha256', signAlg, chain } = {}) {
76
+ let body = `!${MAGIC} ${VERSION}\n!hash ${hashAlg}\n`;
77
+ if (signAlg && chain)
78
+ body += `!sign ${signAlg}\n\n` + String(chain).replace(/\s*$/, '') + '\n';
79
+ return Buffer.from(body, 'utf-8');
80
+ }
81
+ // Split manifest bytes into { version, hashAlg, signAlg, chain }.
82
+ export function parseManifest(content) {
83
+ const text = Buffer.isBuffer(content) ? content.toString('utf-8') : String(content);
84
+ const split = text.indexOf('\n\n');
85
+ const head = split >= 0 ? text.slice(0, split) : text;
86
+ const pem = split >= 0 ? text.slice(split + 2) : '';
87
+ const directives = {};
88
+ for (const line of head.split('\n')) {
89
+ if (!line || line[0] !== '!')
90
+ continue;
91
+ const sp = line.indexOf(' ');
92
+ if (sp < 0)
93
+ directives[line.slice(1)] = '';
94
+ else
95
+ directives[line.slice(1, sp)] = line.slice(sp + 1);
96
+ }
97
+ const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
98
+ return {
99
+ version: directives[MAGIC],
100
+ hashAlg: directives['hash'] || 'sha256',
101
+ signAlg: directives['sign'],
102
+ chain: blocks.map((block) => new CRYPTO.X509Certificate(block)),
103
+ };
104
+ }
105
+ /**
106
+ * Verify an archive. `source` is a filesystem path (opened read-only and read
107
+ * in chunks) or a Buffer of the whole file.
108
+ *
109
+ * `digests` on the result is the member-name -> hex-digest map read from the
110
+ * entry comments of the very archive that was just hashed. A caller that goes
111
+ * on to serve those members (see provider.ts) should keep this map rather than
112
+ * re-reading the comments later, so what it checks content against is what the
113
+ * signature covered.
114
+ */
115
+ export function verifySync(source, options = {}) {
116
+ const io = Buffer.isBuffer(source) ? bufferSource(source) : pathSource(source);
117
+ let reader;
118
+ try {
119
+ reader = readerFor(options.archive ?? io.open());
120
+ return inspect(reader, io, options);
121
+ }
122
+ catch (err) {
123
+ // An archive whose structure no longer holds together has been altered
124
+ // as surely as one whose hash is wrong, so it gets the same answer. A
125
+ // file that is missing or unreadable is still an error: there is
126
+ // nothing there to call invalid.
127
+ if (isZipError(err))
128
+ return result('invalid', `not a readable ZIP archive: ${message(err)}`);
129
+ throw err;
130
+ }
131
+ finally {
132
+ if (!options.archive)
133
+ reader?.close();
134
+ }
135
+ }
136
+ function isZipError(err) {
137
+ const code = err?.code;
138
+ return typeof code === 'string' && code.startsWith('ERR_ZIP_');
139
+ }
140
+ /**
141
+ * Promise-returning wrapper around `verifySync`, for callers that treat
142
+ * verification as an asynchronous step. Hashing is CPU-bound either way, so the
143
+ * work still runs to completion synchronously.
144
+ */
145
+ export async function verify(source, options) {
146
+ return verifySync(source, options);
147
+ }
148
+ function readerFor(archive) {
149
+ if (archive instanceof ZLIB.ZipBuffer) {
150
+ return { entries: () => archive.entries(), close: () => { } };
151
+ }
152
+ return { entries: () => archive.entriesSync(), close: () => archive.closeSync() };
153
+ }
154
+ // The staged check itself. Split out so `verifySync` owns only the lifetime of
155
+ // the archive it may have opened.
156
+ function inspect(reader, io, options) {
157
+ const { extraRoots, now = Date.now(), deep = true, trustedRoot, identity, issuer } = options;
158
+ const present = new Map();
159
+ for (const [name, entry] of reader.entries())
160
+ present.set(name, entry);
161
+ const authority = present.get(AUTHORITY);
162
+ if (!authority)
163
+ return result('unsigned', 'no manifest entry');
164
+ const { hashAlg, signAlg, chain } = parseManifest(authority.contentSync());
165
+ // The signature lives in the EOCD comment as `SIGNED:<hash>:<sig>`; the
166
+ // region the hash covers ends just before the comment's length field.
167
+ const eocd = locateEocd(io.tail(), io.size);
168
+ const marker = parseSignature(eocd.comment.toString('ascii'));
169
+ if (!signAlg || chain.length === 0 || !marker) {
170
+ return result('unsigned', 'manifest carries no signature', chain, { hashAlg });
171
+ }
172
+ const regionEnd = eocd.start + 20; // up to, and excluding, the comment-length field
173
+ // 1. Integrity (the cheap pre-mount gate): recompute the whole-file hash
174
+ // and confirm it matches the hash recorded in the comment. No certs yet.
175
+ let digest;
176
+ try {
177
+ const hash = CRYPTO.createHash(hashAlg);
178
+ io.feed(hash, regionEnd);
179
+ digest = hash.digest('hex');
180
+ }
181
+ catch {
182
+ digest = null;
183
+ }
184
+ if (digest !== marker.hash)
185
+ return result('invalid', 'archive hash does not match the recorded hash', chain, { hashAlg });
186
+ // 2. Authenticity: the recorded hash must be signed by the leaf certificate.
187
+ // Because the signature is over the hash, this needs no re-read of the file.
188
+ const leaf = chain[0];
189
+ let signatureOk = false;
190
+ try {
191
+ signatureOk = CRYPTO.verify(signAlg, Buffer.from(marker.hash, 'hex'), leaf.publicKey, Buffer.from(marker.sig, 'hex'));
192
+ }
193
+ catch {
194
+ signatureOk = false;
195
+ }
196
+ if (!signatureOk)
197
+ return result('invalid', 'signature does not verify against leaf certificate', chain, { hashAlg });
198
+ // 3. Per-member integrity: every member must record a digest of its own
199
+ // content, and — when `deep` — that digest must match what the member
200
+ // actually decompresses to. (The whole-file hash already fixes every
201
+ // member; this checks each file on its own terms, as a member fetch will.)
202
+ const digests = new Map();
203
+ for (const [name, entry] of present) {
204
+ if (name === AUTHORITY || entry.isDirectory)
205
+ continue;
206
+ const recorded = entry.comment || '';
207
+ if (!/^[0-9a-f]+$/i.test(recorded))
208
+ return result('invalid', `member carries no digest: ${name}`, chain, { hashAlg });
209
+ digests.set(name, recorded.toLowerCase());
210
+ if (!deep)
211
+ continue;
212
+ let memberDigest;
213
+ try {
214
+ memberDigest = CRYPTO.createHash(hashAlg).update(entry.contentSync()).digest('hex');
215
+ }
216
+ catch {
217
+ return result('invalid', `member could not be read: ${name}`, chain, { hashAlg, digests });
218
+ }
219
+ if (memberDigest !== recorded.toLowerCase())
220
+ return result('invalid', `digest mismatch: ${name}`, chain, { hashAlg, digests });
221
+ }
222
+ // 4. Signature and digests are sound; what remains is trust — whether this
223
+ // certificate means anything to us. Which question that is depends on
224
+ // what kind of certificate it is.
225
+ const sigstoreField = marker.fields.get(SIGSTORE.FIELD);
226
+ if (sigstoreField) {
227
+ return sigstoreTrust(sigstoreField, marker, chain, { hashAlg, digests, trustedRoot, identity, issuer });
228
+ }
229
+ // A demanded identity is a demand about *who signed this*, and only the
230
+ // sigstore path can answer it. An archive signed against an ordinary CA
231
+ // carries no such claim, so the policy cannot be satisfied — and reporting
232
+ // it as trusted anyway would turn `--identity` into a no-op exactly where
233
+ // it is being relied on. Untrusted rather than invalid: the signature is
234
+ // genuine, it just is not the one that was asked for.
235
+ if (identity || issuer) {
236
+ return result('valid-untrusted', 'a sigstore identity was required but this archive is not sigstore-signed', chain, { hashAlg, digests });
237
+ }
238
+ const roots = trustRoots(extraRoots);
239
+ const ok = anchored(chain, roots, now);
240
+ return result(ok ? 'valid' : 'valid-untrusted', ok ? 'trusted certificate chain' : 'certificate chain not anchored in the trust store', chain, { hashAlg, digests });
241
+ }
242
+ // Trust, for an archive signed through sigstore. This replaces the plain X.509
243
+ // anchoring rather than supplementing it, because the question is different: a
244
+ // Fulcio certificate is valid for about ten minutes, so asking whether it is in
245
+ // date *now* would fail every archive older than lunchtime. What the sigstore
246
+ // bundle carries — a transparency-log entry and an RFC 3161 timestamp — is the
247
+ // evidence needed to ask whether it was in date *when the signature was made*,
248
+ // and `@sigstore/verify` is what checks that end to end: certificate to the
249
+ // Fulcio root, SCT, log inclusion, timestamps, and the signature itself.
250
+ function sigstoreTrust(encoded, marker, chain, { hashAlg, digests, trustedRoot, identity, issuer }) {
251
+ const extra = { hashAlg, digests, sigstore: true };
252
+ const undecided = (reason) => result('valid-untrusted', reason, chain, extra);
253
+ // Not being able to check is not the same answer as checking and finding it
254
+ // forged, so a missing library or trust root degrades rather than fails.
255
+ if (!SIGSTORE.available()) {
256
+ return undecided('archive is sigstore-signed but the sigstore libraries are not installed');
257
+ }
258
+ const root = SIGSTORE.trustedRootSync(trustedRoot);
259
+ if (!root) {
260
+ return undecided('archive is sigstore-signed but no sigstore trust root is available — run `bundle trust`');
261
+ }
262
+ // The bundle rides in the unhashed comment, so it is the one part of the
263
+ // file an attacker can swap freely. Two checks close that off: the bundle
264
+ // must be over this archive's hash (below, via the artifact argument), and
265
+ // it must name the same certificate as the AUTHORITY.PEM that *is* inside
266
+ // the signed region. Without the second, a valid bundle for someone else's
267
+ // identity could be pinned to an archive whose extractable manifest claims
268
+ // a different signer — the signature would check out and the inspectable
269
+ // file would be a lie.
270
+ let cert;
271
+ try {
272
+ cert = SIGSTORE.bundleCertificate(encoded);
273
+ }
274
+ catch (err) {
275
+ return result('invalid', `sigstore bundle could not be read: ${message(err)}`, chain, extra);
276
+ }
277
+ if (!cert)
278
+ return result('invalid', 'sigstore bundle carries no certificate', chain, extra);
279
+ if (!chain[0] || cert.fingerprint256 !== chain[0].fingerprint256) {
280
+ return result('invalid', 'AUTHORITY.PEM does not name the certificate the sigstore bundle was signed with', chain, extra);
281
+ }
282
+ try {
283
+ const res = SIGSTORE.verifyBundle(encoded, Buffer.from(marker.hash, 'hex'), { trustedRoot: root, identity, issuer });
284
+ const when = res.signedAt ? `, signed ${res.signedAt.toISOString()}` : '';
285
+ 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 });
286
+ }
287
+ catch (err) {
288
+ // A policy failure means the signature is genuine and the signer is
289
+ // simply not the one that was demanded — untrusted, not tampered.
290
+ if (err instanceof Error && err.name === 'PolicyError') {
291
+ return undecided(`sigstore identity does not match the required policy: ${err.message}`);
292
+ }
293
+ return result('invalid', `sigstore verification failed: ${message(err)}`, chain, extra);
294
+ }
295
+ }
296
+ /**
297
+ * Parse an EOCD comment of the form
298
+ * `SIGNED:<hash-hex>:<signature-hex>[:<NAME>=<value>]*`, or null when the
299
+ * archive is unsigned (no such marker). Field values never contain a `:`, which
300
+ * is what keeps splitting on it unambiguous (base64 does not use one).
301
+ */
302
+ export function parseSignature(comment) {
303
+ const m = /^SIGNED:([0-9a-f]+):([0-9a-f]+)((?::[A-Za-z0-9_]+=[^:]*)*)$/.exec(String(comment).trim());
304
+ if (!m)
305
+ return null;
306
+ const fields = new Map();
307
+ for (const part of m[3].split(':')) {
308
+ if (!part)
309
+ continue;
310
+ const eq = part.indexOf('=');
311
+ fields.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
312
+ }
313
+ return { hash: m[1].toLowerCase(), sig: m[2].toLowerCase(), fields };
314
+ }
315
+ /**
316
+ * The inverse: render a marker for the EOCD comment. Field order is fixed by
317
+ * insertion, and a field whose value is empty or absent is left out entirely.
318
+ */
319
+ export function formatSignature({ hash, sig, fields }) {
320
+ const parts = [`SIGNED:${hash}:${sig}`];
321
+ for (const [name, value] of Object.entries(fields ?? {})) {
322
+ if (value === undefined || value === null || value === '')
323
+ continue;
324
+ if (!/^[A-Za-z0-9_]+$/.test(name))
325
+ throw new Error(`invalid signature field name: ${name}`);
326
+ if (String(value).includes(':'))
327
+ throw new Error(`signature field '${name}' may not contain ':'`);
328
+ parts.push(`${name.toUpperCase()}=${value}`);
329
+ }
330
+ return parts.join(':');
331
+ }
332
+ /**
333
+ * The signature marker carried by `source`, or null when it carries none —
334
+ * including when it is not a ZIP at all. Reads only the tail of the file, so it
335
+ * is cheap enough to use as a "does this claim to be one of ours?" test before
336
+ * committing to a full verification.
337
+ */
338
+ export function signatureOf(source) {
339
+ try {
340
+ const io = Buffer.isBuffer(source) ? bufferSource(source) : pathSource(source);
341
+ return parseSignature(locateEocd(io.tail(), io.size).comment.toString('ascii'));
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ }
347
+ function pathSource(path) {
348
+ const size = FS.statSync(path).size;
349
+ return {
350
+ size,
351
+ tail() {
352
+ const len = Math.min(size, 22 + 0xffff);
353
+ const buf = Buffer.alloc(len);
354
+ const fd = FS.openSync(path, 'r');
355
+ try {
356
+ FS.readSync(fd, buf, 0, len, size - len);
357
+ }
358
+ finally {
359
+ FS.closeSync(fd);
360
+ }
361
+ return buf;
362
+ },
363
+ feed(sink, end) {
364
+ const fd = FS.openSync(path, 'r');
365
+ try {
366
+ const buf = Buffer.allocUnsafe(Math.min(CHUNK, end));
367
+ let pos = 0;
368
+ while (pos < end) {
369
+ const read = FS.readSync(fd, buf, 0, Math.min(buf.length, end - pos), pos);
370
+ if (read <= 0)
371
+ throw new Error('unexpected end of file');
372
+ sink.update(buf.subarray(0, read));
373
+ pos += read;
374
+ }
375
+ }
376
+ finally {
377
+ FS.closeSync(fd);
378
+ }
379
+ },
380
+ open: () => openArchive(path),
381
+ };
382
+ }
383
+ function bufferSource(buf) {
384
+ return {
385
+ size: buf.length,
386
+ tail: () => buf.subarray(Math.max(0, buf.length - (22 + 0xffff))),
387
+ feed: (sink, end) => { sink.update(buf.subarray(0, end)); },
388
+ open: () => new ZLIB.ZipBuffer(buf),
389
+ };
390
+ }
391
+ // Find the end-of-central-directory record in `tail` (the last bytes of a file
392
+ // of total length `size`) and return { start, comment } with `start` absolute.
393
+ // The EOCD must be the last structure in the file, so its comment runs to EOF.
394
+ function locateEocd(tail, size) {
395
+ const floor = Math.max(0, tail.length - (22 + 0xffff));
396
+ const scan = (exact) => {
397
+ for (let pos = tail.length - 22; pos >= floor; pos--) {
398
+ if (tail.readUInt32LE(pos) !== SIG_EOCD)
399
+ continue;
400
+ const end = pos + 22 + tail.readUInt16LE(pos + 20);
401
+ if (exact ? end !== tail.length : end > tail.length)
402
+ continue;
403
+ return pos;
404
+ }
405
+ return -1;
406
+ };
407
+ let pos = scan(true);
408
+ if (pos < 0)
409
+ pos = scan(false);
410
+ if (pos < 0)
411
+ throw new Error('no end of central directory record found');
412
+ const clen = tail.readUInt16LE(pos + 20);
413
+ return { start: size - tail.length + pos, comment: tail.subarray(pos + 22, pos + 22 + clen) };
414
+ }
415
+ function openArchive(path) {
416
+ try {
417
+ return ZLIB.ZipFile.openSync(path);
418
+ }
419
+ catch (err) {
420
+ // A path that is itself a mount point resolves to the mounted tree
421
+ // rather than to bytes, and opening a directory as a ZIP fails deep
422
+ // inside with a confusing message. (A container's *own* path is not one
423
+ // of these: `--vfs-load` leaves it readable, which is what lets a
424
+ // launcher verify itself. This is for a directory mount, or a mount
425
+ // deliberately placed over an archive.)
426
+ let isDir = false;
427
+ try {
428
+ isDir = FS.statSync(path).isDirectory();
429
+ }
430
+ catch { /* fall through */ }
431
+ if (isDir) {
432
+ throw new Error(`cannot verify '${path}': it is mounted as a live filesystem ` +
433
+ `(the running container cannot read its own container bytes by path — ` +
434
+ `verify it under a different name)`);
435
+ }
436
+ throw err;
437
+ }
438
+ }
439
+ function result(state, reason, chain, extra) {
440
+ return {
441
+ state,
442
+ reason,
443
+ subject: chain && chain[0] ? chain[0].subject : undefined,
444
+ signed: state !== 'unsigned',
445
+ trusted: state === 'valid',
446
+ ...extra,
447
+ };
448
+ }
449
+ function trustRoots(extra) {
450
+ const pems = [...cas('system'), ...cas('extra'), ...(extra || [])];
451
+ return pems.map((pem) => new CRYPTO.X509Certificate(pem));
452
+ }
453
+ function cas(type) {
454
+ try {
455
+ return TLS.getCACertificates(type) || [];
456
+ }
457
+ catch {
458
+ return [];
459
+ }
460
+ }
461
+ function within(cert, now) {
462
+ return Date.parse(cert.validFrom) <= now && now <= Date.parse(cert.validTo);
463
+ }
464
+ // id-kp-codeSigning (RFC 5280 §4.2.1.12).
465
+ const CODE_SIGNING = '1.3.6.1.5.5.7.3.3';
466
+ // Path validation: every link in the supplied chain must be issuer-signed and
467
+ // in-date, and the top of the chain must be, or be issued by, a trusted root.
468
+ //
469
+ // Signing alone is not enough to be a link. The roots include the system store,
470
+ // so a chain that only had to be *issued* would let the key of any publicly
471
+ // trusted certificate — a web server's TLS certificate, say — sign an archive
472
+ // that reports as trusted. So the leaf must say it is for signing code, and
473
+ // everything that vouches for it must be a CA.
474
+ function anchored(chain, roots, now) {
475
+ const [leaf] = chain;
476
+ if (!leaf || !leaf.keyUsage?.includes(CODE_SIGNING))
477
+ return false;
478
+ for (const cert of chain)
479
+ if (!within(cert, now))
480
+ return false;
481
+ for (let i = 0; i < chain.length - 1; i++) {
482
+ if (!chain[i + 1].ca)
483
+ return false;
484
+ if (!chain[i].checkIssued(chain[i + 1]))
485
+ return false;
486
+ if (!chain[i].verify(chain[i + 1].publicKey))
487
+ return false;
488
+ }
489
+ const top = chain[chain.length - 1];
490
+ for (const root of roots) {
491
+ // A root named directly is trusted as itself, whatever it is: that is
492
+ // pinning, and the caller chose it.
493
+ if (top.fingerprint256 === root.fingerprint256)
494
+ return true;
495
+ if (root.ca && top.checkIssued(root) && top.verify(root.publicKey) && within(root, now))
496
+ return true;
497
+ }
498
+ return false;
499
+ }
500
+ /** An error's message, for errors that arrive as `unknown`. */
501
+ export function message(err) {
502
+ return err instanceof Error ? err.message : String(err);
503
+ }
504
+ //# sourceMappingURL=manifest.js.map