@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.
- package/HISTORY.md +1924 -0
- package/README.md +623 -0
- package/bundle.run +0 -0
- package/dist/api.d.ts +147 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +174 -0
- package/dist/api.js.map +1 -0
- package/dist/archive.d.ts +115 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +188 -0
- package/dist/archive.js.map +1 -0
- package/dist/audit.d.ts +78 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +119 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +555 -0
- package/dist/cli.js.map +1 -0
- package/dist/files.d.ts +53 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +118 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +97 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +267 -0
- package/dist/launch.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +19 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest.d.ts +139 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +504 -0
- package/dist/manifest.js.map +1 -0
- package/dist/oidc.d.ts +40 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +320 -0
- package/dist/oidc.js.map +1 -0
- package/dist/preload.d.ts +14 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +38 -0
- package/dist/preload.js.map +1 -0
- package/dist/provider.d.ts +83 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +206 -0
- package/dist/provider.js.map +1 -0
- package/dist/record.d.ts +2 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +23 -0
- package/dist/record.js.map +1 -0
- package/dist/recorder.d.ts +64 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +111 -0
- package/dist/recorder.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +28 -0
- package/dist/register.js.map +1 -0
- package/dist/sea.d.ts +97 -0
- package/dist/sea.d.ts.map +1 -0
- package/dist/sea.js +220 -0
- package/dist/sea.js.map +1 -0
- package/dist/sigstore.d.ts +112 -0
- package/dist/sigstore.d.ts.map +1 -0
- package/dist/sigstore.js +385 -0
- package/dist/sigstore.js.map +1 -0
- package/dist/skill.d.ts +36 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +108 -0
- package/dist/skill.js.map +1 -0
- package/package.json +84 -0
- package/shell-base +2 -0
- package/skills/audit-bundle/SKILL.md +271 -0
- package/src/api.ts +293 -0
- package/src/archive.ts +312 -0
- package/src/audit.ts +206 -0
- package/src/cli.ts +575 -0
- package/src/files.ts +156 -0
- package/src/index.ts +114 -0
- package/src/launch.ts +336 -0
- package/src/main.ts +20 -0
- package/src/manifest.ts +615 -0
- package/src/oidc.ts +372 -0
- package/src/preload.ts +40 -0
- package/src/provider.ts +270 -0
- package/src/record.ts +25 -0
- package/src/recorder.ts +166 -0
- package/src/register.ts +30 -0
- package/src/sea.ts +341 -0
- package/src/sigstore.ts +492 -0
- package/src/skill.ts +132 -0
- package/src/types/node-vfs.d.ts +90 -0
- package/src/types/node-zip.d.ts +85 -0
package/HISTORY.md
ADDED
|
@@ -0,0 +1,1924 @@
|
|
|
1
|
+
# bundles — the argument
|
|
2
|
+
|
|
3
|
+
> **This is the long-form document: why this project exists, what it is arguing about
|
|
4
|
+
> Node.js packaging and software supply chains, and how the design got to where it is.**
|
|
5
|
+
> It was the README for most of the project's life and is kept as the reasoning, which is
|
|
6
|
+
> worth more than the diff.
|
|
7
|
+
>
|
|
8
|
+
> For **using** the tool — install, commands, API, the four steps of building a bundle —
|
|
9
|
+
> see [README.md](README.md). Design decisions written down before they were built, and
|
|
10
|
+
> what departed from the plan, are the [implementation notes](#implementation-notes) at
|
|
11
|
+
> the end of this document.
|
|
12
|
+
>
|
|
13
|
+
> Paths and script names here are current as of writing; where the repository has since
|
|
14
|
+
> moved things, the commands have been updated but the narrative has not been rewritten.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
A tool — **`bundle`** — for **bundling and distributing Node.js applications as single,
|
|
19
|
+
signed files**, and the experiment that produced it. It is one TypeScript package, published
|
|
20
|
+
as ESM, with four things in it:
|
|
21
|
+
|
|
22
|
+
- **`@pipobscure/bundle/record`** — a `node:vfs` provider you preload with `-r` that writes
|
|
23
|
+
down every file a run actually reads, so the list of what to archive comes from
|
|
24
|
+
observation rather than guesswork. It is the userland replacement for the
|
|
25
|
+
`--vfs-manifest` flag.
|
|
26
|
+
- **`@pipobscure/bundle/register`** — a **verifying `node:vfs` provider** you preload with
|
|
27
|
+
`-r` (or `--import`), so `node --vfs-load=app.bundle` mounts and runs an
|
|
28
|
+
application *only* if it is properly signed, and checks each member against its recorded
|
|
29
|
+
digest as that member is read.
|
|
30
|
+
- **`@pipobscure/bundle`** — the same operations as an API: `createBundle`, `signBundle`,
|
|
31
|
+
`verifyBundle`, `inspectBundle`, `runBundle`. The CLI is a `parseArgs` wrapper over these
|
|
32
|
+
and nothing else, so an embedder can do everything the command line can.
|
|
33
|
+
- **`bundle`**, the CLI — `create`, `sign`, `verify`, `run`, `sea`, `trust`, `skill`.
|
|
34
|
+
|
|
35
|
+
Plus **`@pipobscure/bundle/sea`**, which puts the verifying mount inside a single executable:
|
|
36
|
+
a Node runtime, this package as a mounted archive in its own SEA blob, and the application
|
|
37
|
+
appended as a signed archive — so the finished binary checks its own signature before
|
|
38
|
+
running anything, with the checker inside what is checked.
|
|
39
|
+
|
|
40
|
+
Signing is a step of its own rather than part of building, and that is what makes one build
|
|
41
|
+
serve every target. `create` produces an unsigned archive; `sign` re-emits it behind
|
|
42
|
+
whatever prefix you name and signs the finished bytes. So a single `app.bundle` becomes a
|
|
43
|
+
`#!` launcher, a self-contained executable and a plain mountable archive — each correctly
|
|
44
|
+
offset, each signed over itself.
|
|
45
|
+
|
|
46
|
+
That split is also what makes room for the step in the middle. Building a bundle goes:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
1. observe run the application, and write down every file it actually reads
|
|
50
|
+
2. create archive exactly that list, unsigned
|
|
51
|
+
3. audit review it — against the last release, if there is one
|
|
52
|
+
4. sign only if step 3 came back clean
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A signature is a claim about bytes you stand behind, so the review belongs *before* it,
|
|
56
|
+
not after. Step 3 is the [`audit-bundle` skill](skills/audit-bundle/SKILL.md), and it is
|
|
57
|
+
the same review whoever receives the bundle should run before trusting it — which is the
|
|
58
|
+
point: hold your own artifact to the standard you would hold someone else's.
|
|
59
|
+
|
|
60
|
+
A signature says who produced the bytes; it says nothing about whether they are safe.
|
|
61
|
+
Because a bundle is a closed set — nothing resolves later, nothing is fetched at install,
|
|
62
|
+
no lifecycle script pulls in more code — a review over one can actually be complete, which
|
|
63
|
+
is what makes step 3 worth doing at all.
|
|
64
|
+
|
|
65
|
+
The result is an application in one file that the runtime itself refuses to run when it
|
|
66
|
+
has been tampered with — either as a plain `.bundle` archive, as a small self-executing ZIP
|
|
67
|
+
that runs on any installed Node, or as a fully self-contained native executable that needs
|
|
68
|
+
no Node at all.
|
|
69
|
+
|
|
70
|
+
It is driven by Node.js itself, in three additions on top of Node's existing experimental
|
|
71
|
+
**virtual file system** (`node:vfs`, by Matteo Collina). All three have shipped since this
|
|
72
|
+
was written, the last of them in v26.10.0:
|
|
73
|
+
|
|
74
|
+
1. **ZIP archive support in `node:zlib`** —
|
|
75
|
+
[nodejs/node#64339](https://github.com/nodejs/node/pull/64339), released in **v26.8.0** —
|
|
76
|
+
plus a **`ZipProvider`** that mounts such an archive through VFS as a file tree,
|
|
77
|
+
[nodejs/node#64915](https://github.com/nodejs/node/pull/64915), released in **v26.9.0**.
|
|
78
|
+
2. A **`--vfs-load` module loader** that mounts a directory or an archive and resolves a
|
|
79
|
+
program's entry point and all its `require()`/`import` against it —
|
|
80
|
+
[nodejs/node#65748](https://github.com/nodejs/node/pull/65748), released in **v26.10.0**.
|
|
81
|
+
3. **`vfs.registerProvider()`**, the extension point that lets a preloaded module
|
|
82
|
+
decide which provider backs a mount — which is what makes a *verifying* mount, or a
|
|
83
|
+
*recording* one, possible from userland at all. It came in the same pull request as the
|
|
84
|
+
flags, which is why that one was the piece nothing here could run without.
|
|
85
|
+
|
|
86
|
+
Together they let the root a program runs from be a plain `.zip` embedded inside the
|
|
87
|
+
program's own file. Combined with Node's newer **Single Executable Application (SEA)**
|
|
88
|
+
tooling, this turns "an application plus its files" into "one file you can `chmod +x` and
|
|
89
|
+
run."
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Why this exists
|
|
94
|
+
|
|
95
|
+
Shipping a Node application to someone else is still awkward. The options today are all
|
|
96
|
+
compromises:
|
|
97
|
+
|
|
98
|
+
- **A directory of files + `npm install`.** The user needs the right Node, a working
|
|
99
|
+
toolchain, and network access; `node_modules` is enormous and platform-specific for
|
|
100
|
+
anything with native addons.
|
|
101
|
+
- **A bundler (esbuild/webpack/ncc).** Collapses JS into one file, but assets, addons,
|
|
102
|
+
and anything that does `fs.readFile(__dirname + ...)` still leak out. You are shipping
|
|
103
|
+
a JS blob, not an application.
|
|
104
|
+
- **`pkg` / SEA.** Produce a real executable, but historically SEA only took a **single
|
|
105
|
+
CommonJS script**, code caching and asset handling were fiddly, and building one meant
|
|
106
|
+
bolting a WASM copy of `postject` onto the side of your build to inject a blob into the
|
|
107
|
+
binary.
|
|
108
|
+
|
|
109
|
+
The thing all of these dance around is that a real application is a *file tree*: an entry
|
|
110
|
+
point, sibling modules, a `package.json`, templates, static assets, maybe a native addon.
|
|
111
|
+
Node's module resolution and every `fs` call assume that tree lives on the real disk. If
|
|
112
|
+
you want to ship the tree *inside* a single file, you need Node to be able to treat
|
|
113
|
+
something-that-isn't-a-directory as the directory it resolves against.
|
|
114
|
+
|
|
115
|
+
That is exactly what these additions to Node provide.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## What changed in node
|
|
120
|
+
|
|
121
|
+
Three layers matter here, and it's worth being precise about who wrote what and where each
|
|
122
|
+
one lives:
|
|
123
|
+
|
|
124
|
+
- **The `node:vfs` subsystem is pre-existing.** It was written and merged (as an
|
|
125
|
+
experimental builtin) by **Matteo Collina** — not part of this work. It's summarized
|
|
126
|
+
below only because it's the foundation everything else stands on.
|
|
127
|
+
- **The novel work is two additions to Node:**
|
|
128
|
+
- **ZIP archive support in `node:zlib`** ([nodejs/node#64339](https://github.com/nodejs/node/pull/64339),
|
|
129
|
+
released in v26.8.0) and the **`ZipProvider`** that mounts an archive through VFS
|
|
130
|
+
([nodejs/node#64915](https://github.com/nodejs/node/pull/64915), released in v26.9.0).
|
|
131
|
+
- The **`--vfs-load` module loader** that makes a mounted tree the thing a program
|
|
132
|
+
actually resolves and runs from, and the provider registry that decides what backs a
|
|
133
|
+
mount — [nodejs/node#65748](https://github.com/nodejs/node/pull/65748), released in
|
|
134
|
+
v26.10.0. Loading a **native addon** out of a mount
|
|
135
|
+
was a separate pull request, [nodejs/node#65680](https://github.com/nodejs/node/pull/65680),
|
|
136
|
+
released in v26.9.0.
|
|
137
|
+
- **The SEA group** is recent upstream Node functionality the experiment leans on, carried
|
|
138
|
+
along so the whole pipeline works from one binary.
|
|
139
|
+
|
|
140
|
+
### 0. Foundation (pre-existing): `node:vfs` — a virtual file system with pluggable providers
|
|
141
|
+
|
|
142
|
+
*By Matteo Collina; here for context, not part of this work's contribution.* An experimental
|
|
143
|
+
builtin (`--experimental-vfs` to enable) exposing a `node:fs`-shaped API backed by a
|
|
144
|
+
swappable **provider**:
|
|
145
|
+
|
|
146
|
+
- **`MemoryProvider`** — an in-memory tree (the default); supports symlinks and watching,
|
|
147
|
+
and can be frozen read-only.
|
|
148
|
+
- **`RealFSProvider`** — wraps a real directory and maps every VFS path under it,
|
|
149
|
+
rejecting paths (and symlinks) that resolve outside the root. It gives a subtree *path
|
|
150
|
+
containment* it wouldn't otherwise have.
|
|
151
|
+
|
|
152
|
+
The full synchronous / callback / promise surfaces of `fs` are mirrored, and `Stats`
|
|
153
|
+
objects are real `fs.Stats`. Crucially, the docs are explicit that **VFS is not a sandbox** —
|
|
154
|
+
it redirects supported `fs` calls whose resolved path falls under a mount; it is not a
|
|
155
|
+
security boundary. That honesty matters for how it's positioned below.
|
|
156
|
+
|
|
157
|
+
### 1. ZIP support in `node:zlib` *([nodejs/node#64339](https://github.com/nodejs/node/pull/64339) — merged, released in v26.8.0)*
|
|
158
|
+
|
|
159
|
+
`node:zlib` gains a small archive toolkit:
|
|
160
|
+
|
|
161
|
+
- **`ZipEntry`** — one immutable archive member (name, metadata, content), created from a
|
|
162
|
+
buffer or stream, or read back from raw bytes.
|
|
163
|
+
- **`ZipFile`** — a ZIP on disk, opened read-only by default (`{ writable: true }` to
|
|
164
|
+
mutate), with get/add/delete/stream-by-name and `compact()` to reclaim deleted space.
|
|
165
|
+
- **`ZipBuffer`** — the fully in-memory equivalent, serializable back to a `Buffer`.
|
|
166
|
+
- **`createZipArchive()` / `...Sync()`** — build a fresh archive from a list of entries,
|
|
167
|
+
returned as a `Readable` you can pipe straight to a file or socket.
|
|
168
|
+
|
|
169
|
+
Two details make the whole single-file trick possible:
|
|
170
|
+
|
|
171
|
+
- **`baseOffset`** — an archive records internal offsets; seeding them with a base offset
|
|
172
|
+
lets the archive stay valid even when it is **not at byte 0 of its file** — e.g. when
|
|
173
|
+
it's appended *after* a shebang line or after an entire Node binary.
|
|
174
|
+
- Read paths enforce content-size limits and reject malformed records (zip-bomb / corrupt
|
|
175
|
+
input guards), with dedicated `ERR_ZIP_*` codes.
|
|
176
|
+
|
|
177
|
+
### 2. `ZipProvider` — a VFS provider backed by a ZIP archive *([nodejs/node#64915](https://github.com/nodejs/node/pull/64915) — released in v26.9.0)*
|
|
178
|
+
|
|
179
|
+
The bridge between the two: a provider for Matteo's `node:vfs` that exposes the entries of
|
|
180
|
+
a `ZipFile` (on disk) or `ZipBuffer` (in memory) as a browsable, read/write file tree.
|
|
181
|
+
Directories are recognized both explicitly and implicitly; a file opened for write commits
|
|
182
|
+
as a new archive entry when its handle is closed. This is what lets a `.zip` be *mounted*
|
|
183
|
+
and treated like a directory.
|
|
184
|
+
|
|
185
|
+
### 3. `--vfs-load` — the keystone *([nodejs/node#65748](https://github.com/nodejs/node/pull/65748) — released in v26.10.0)*
|
|
186
|
+
|
|
187
|
+
This is what wires VFS into Node's *startup and module resolution* so a mounted tree
|
|
188
|
+
becomes the thing the program actually runs from. One flag does both halves: it mounts a
|
|
189
|
+
source and runs the program out of it.
|
|
190
|
+
|
|
191
|
+
- **`--vfs-load=<source>`** mounts `<source>` at a reserved mount point Node assigns, and
|
|
192
|
+
runs the entry point out of that mount, resolving it *and all subsequent `require()` /
|
|
193
|
+
`import`* against it instead of the real filesystem. It may be given at most once. The
|
|
194
|
+
mount's own `package.json` `"main"` decides what runs; a positional argument is the
|
|
195
|
+
program's own argument (from `argv[2]` on), never an entry-point override. `argv[1]`
|
|
196
|
+
reports the *source* rather than the mount point — which is what lets a launcher archive
|
|
197
|
+
read its own bytes and verify itself.
|
|
198
|
+
- The provider is chosen from the **source itself, not its name**: a **directory** is
|
|
199
|
+
mounted with `RealFSProvider`, and a **file whose bytes are a ZIP archive** with
|
|
200
|
+
`ZipProvider` — so an archive can be called anything at all.
|
|
201
|
+
- The mount point is deliberately not yours to choose, so a mount never shadows a real
|
|
202
|
+
path and no invocation can redirect one tree onto another. It is not yours to *name*
|
|
203
|
+
either: named mounts ([nodejs/node#66119](https://github.com/nodejs/node/pull/66119))
|
|
204
|
+
were proposed and closed.
|
|
205
|
+
- **It shipped with a second flag, and is losing it.** v26.10.0 also carries
|
|
206
|
+
`--vfs-mount`, which mounted a source without running it; the two appended to one list,
|
|
207
|
+
so `--vfs-load`'s entry point had to be recovered from its position among the mounts. The
|
|
208
|
+
next patch release removes it ([nodejs/node#66162](https://github.com/nodejs/node/pull/66162)),
|
|
209
|
+
on the grounds that nothing needs more than one mount from the command line — a program
|
|
210
|
+
that wants more mounts them through `node:vfs`, where it also holds the instance — and
|
|
211
|
+
reserves **layer 0** for the `--vfs-load` source, so it sits at the same mount point in
|
|
212
|
+
every thread whatever else has been mounted, with a program's own mounts numbered from 1.
|
|
213
|
+
- Before that, `--vfs-load` selected a mount by 0-based *index*, until early September
|
|
214
|
+
2026 — which meant counting `--vfs-mount`s out by hand and left the flag's value
|
|
215
|
+
optional, and an optional value needs an alias onto a hidden index flag, the trick
|
|
216
|
+
`--inspect=<port>` uses. Naming the source removed the flag, the range check and the
|
|
217
|
+
counting; dropping `--vfs-mount` removes the list the index pointed into.
|
|
218
|
+
- **`--vfs-load` is refused in `NODE_OPTIONS`**: which entry point runs is the command
|
|
219
|
+
line's decision, and the environment must not be able to redirect any invocation on the
|
|
220
|
+
machine.
|
|
221
|
+
- The entry-point rule is precisely what makes a **self-mounting shebang** work:
|
|
222
|
+
`#!/usr/bin/env -S node --vfs-load`. The kernel appends the script's own path as the value
|
|
223
|
+
of the trailing `--vfs-load`, so the script mounts *itself* and runs its embedded
|
|
224
|
+
`package.json` main — one flag now that the flag names its source.
|
|
225
|
+
- To make this real, four module-resolution primitives (package.json reading,
|
|
226
|
+
nearest-scope lookup, legacy main resolution, extensionless format sniffing) were
|
|
227
|
+
changed to stop calling native bindings directly and instead go through a VFS-aware path
|
|
228
|
+
— deferring unchanged to the real bindings whenever no mount is active, so non-mounted
|
|
229
|
+
behavior is identical.
|
|
230
|
+
- **Worker threads** inherit the active mounts — including when constructed with an
|
|
231
|
+
explicit `execArgv`, which would otherwise get a fresh options parse and escape the
|
|
232
|
+
mount — so code cannot spawn an "escaped" worker. In a worker `--vfs-load` mounts but does
|
|
233
|
+
not load: the same sources are mounted in the same order, so the reserved paths line up,
|
|
234
|
+
and the worker runs its own entry point.
|
|
235
|
+
- **Native addons** were a separate pull request,
|
|
236
|
+
[nodejs/node#65680](https://github.com/nodejs/node/pull/65680), released in v26.9.0. The
|
|
237
|
+
reason they needed one is that
|
|
238
|
+
`dlopen()`/`LoadLibrary()` open a shared object *by path* and a VFS path has no inode to
|
|
239
|
+
open. It reads the addon's bytes out of the mount and loads them from a private,
|
|
240
|
+
self-cleaning image using the smallest on-disk footprint each platform allows: an
|
|
241
|
+
anonymous `memfd` through `/proc/self/fd` on Linux, so the bytes never touch the file
|
|
242
|
+
system at all; a file in a `0700` `mkdtemp()` directory, unlinked immediately after
|
|
243
|
+
loading, elsewhere on POSIX; a `FILE_FLAG_DELETE_ON_CLOSE` temp file on Windows. Addons on
|
|
244
|
+
the real file system are untouched and load directly. Before it, a bundle whose dependency
|
|
245
|
+
tree contained a `.node` file mounted and then failed at `require` — the last thing a bundle
|
|
246
|
+
could not carry.
|
|
247
|
+
|
|
248
|
+
Recording the path of *every file actually read through a mount* — by module resolution or
|
|
249
|
+
by the program's own `fs` calls — used to be a third flag, `--vfs-manifest`, implemented as
|
|
250
|
+
an observer hook inside `node:vfs`. It is now a **provider** instead, in this repo: see
|
|
251
|
+
[Recording the manifest](#recording-the-manifest). Either way it gives you a **dependency
|
|
252
|
+
manifest by observation**: run the app once, and you get the exact minimal set of files it
|
|
253
|
+
touches — the correct contents for the archive you're about to build.
|
|
254
|
+
|
|
255
|
+
### 4. `vfs.registerProvider()` — choosing what backs a mount *(part of [nodejs/node#65748](https://github.com/nodejs/node/pull/65748))*
|
|
256
|
+
|
|
257
|
+
A mount source is not hard-wired to the built-in provider for its kind. `node:vfs` exposes
|
|
258
|
+
a small registry:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
vfs.registerProvider({ name, canHandle(resolvedPath, stats), create(resolvedPath, stats) });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Registered providers are consulted **before** the built-ins — the `ZipProvider` for an
|
|
265
|
+
archive and the `RealFSProvider` for a directory are themselves just the last two entries —
|
|
266
|
+
newest first, and selection happens *after* preload modules have run. That is the whole
|
|
267
|
+
point: a preloaded module can install a provider for the source about to be mounted. A
|
|
268
|
+
registered provider is offered **directories as well as files**, so it can back, wrap or vet
|
|
269
|
+
any source rather than only adding a format.
|
|
270
|
+
|
|
271
|
+
```sh
|
|
272
|
+
node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`canHandle` receives the `statSync()` of the source, so a provider can claim archives, or
|
|
276
|
+
directories, or both — and because the built-ins are last, it can *wrap* either one rather
|
|
277
|
+
than only adding new formats.
|
|
278
|
+
|
|
279
|
+
Because a registered provider outranks the built-in one even for a file the built-in would
|
|
280
|
+
happily handle, it can also **vet** a file rather than merely add a format. That is exactly
|
|
281
|
+
what this repo does with `.bundle`: the provider claims the file, verifies it, and either
|
|
282
|
+
returns a filesystem or throws — and a throw during provider selection means the process
|
|
283
|
+
never reaches the entry point.
|
|
284
|
+
|
|
285
|
+
Two consequences worth stating plainly:
|
|
286
|
+
|
|
287
|
+
- **Either preload flag works, because mounting waits for both.** `-r` modules run during
|
|
288
|
+
bootstrap; `--import` modules are only evaluated later, on the way into the entry point.
|
|
289
|
+
Mounting is therefore deferred past *both* — when `--import` is present the mounts are
|
|
290
|
+
made from `asyncRunEntryPointWithESMLoader`, and idempotently, so a registration from
|
|
291
|
+
either flag is in place before any provider is chosen.
|
|
292
|
+
- **Claim by content, not just by name.** The built-in provider recognizes a ZIP by
|
|
293
|
+
sniffing its leading bytes, so an archive can be called anything. A verifying provider
|
|
294
|
+
that only claimed `*.bundle` could be bypassed by renaming the file, which is why the one
|
|
295
|
+
here also claims anything carrying a `SIGNED:` marker.
|
|
296
|
+
|
|
297
|
+
### SEA support carried along
|
|
298
|
+
|
|
299
|
+
The fork also carries Node's newer SEA work so a single binary can build a SEA end-to-end:
|
|
300
|
+
`--build-sea <config.json>` generates a SEA directly from core (using LIEF instead of a
|
|
301
|
+
bolted-on WASM `postject`), plus **ESM entry-point support** in SEA (`"mainFormat"`) and
|
|
302
|
+
code-cache support for it. That is why `bundle sea` is a single `node --build-sea` call and
|
|
303
|
+
not a build pipeline of its own.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## The experiment in this repo
|
|
308
|
+
|
|
309
|
+
The tool bundles itself. That is the demonstration: the same pipeline you would run over
|
|
310
|
+
your application is the one that produces the `bundle` command npm installs, and every
|
|
311
|
+
property this README claims is exercised by the way the tool arrives on your machine.
|
|
312
|
+
|
|
313
|
+
### The pieces
|
|
314
|
+
|
|
315
|
+
Everything is TypeScript under `src/`, compiled to ESM in `dist/`. The source is written in
|
|
316
|
+
erasable syntax only, so `node src/main.ts` runs it directly through node's type stripping —
|
|
317
|
+
the tests import the sources rather than the build for exactly that reason.
|
|
318
|
+
|
|
319
|
+
| File | Role |
|
|
320
|
+
|------|------|
|
|
321
|
+
| `src/manifest.ts` | The format: `buildManifest()` / `parseManifest()`, `parseSignature()` / `formatSignature()`, `signatureOf()`, and `verifySync()` — the staged check that decides one of four states. |
|
|
322
|
+
| `src/archive.ts` | The bundler: optionally writes a **prefix** (shebang stub or Node binary), then appends a ZIP of the listed files — each stamped with its content digest — with a `baseOffset` equal to the prefix size and an `AUTHORITY.PEM` manifest, then signs the whole file into the EOCD comment. `bundle()` builds from a directory; `rebundle()` re-emits an existing archive behind a new prefix, which is what `sign` runs. |
|
|
323
|
+
| `src/api.ts` | The programmatic drive — `createBundle`, `signBundle`, `verifyBundle`, `inspectBundle`, `runBundle` — with the file plumbing the CLI would otherwise be the only user of. |
|
|
324
|
+
| `src/cli.ts` | Argument parsing and reporting, and nothing else. `main(argv, io)` returns an exit code instead of exiting, so it is callable in-process; most of the CLI test suite does exactly that. |
|
|
325
|
+
| `src/main.ts` | The executable entry: `process.exitCode = await main(process.argv.slice(2))`. Also the package's `main`, which is what `--vfs-load` runs out of a mounted bundle. |
|
|
326
|
+
| `src/provider.ts` | The verifying VFS provider: verifies an archive before it becomes a filesystem, then hashes each member as it is fetched. `register()` installs it with `node:vfs`. |
|
|
327
|
+
| `src/register.ts` | The `-r` preload entry point — one call to `register()`, configured through the environment. |
|
|
328
|
+
| `src/recorder.ts` | The recording provider: wraps a provider class so every read through it is appended to a manifest. Replaces the `--vfs-manifest` flag. |
|
|
329
|
+
| `src/record.ts` | The `-r` preload for recording — set `BUNDLE_MANIFEST` and mount a directory. |
|
|
330
|
+
| `src/launch.ts` | The verification entry point: verify a container, mount it, run what is inside — as an executable checking itself, as a runtime handed an archive, or as a library call. Carries the verifying node's own command line. |
|
|
331
|
+
| `src/sea.ts` | Building the executables: `createSeaBase()` / `buildSea()`, the generated ES-module stub, and the self-test that runs the result once before handing it back. |
|
|
332
|
+
| `src/sigstore.ts` | Sigstore as one of the signers the format can carry: a two-phase signer (get the Fulcio certificate, *then* sign the finished hash), synchronous bundle verification, and the trust root. |
|
|
333
|
+
| `src/oidc.ts` | Getting an OIDC identity token — an ambient CI token, a browser sign-in through sigstore's Dex, or a device code. No dependencies of its own. |
|
|
334
|
+
| `src/files.ts` | Working out a member list the way observation cannot: a dependency closure resolved through `node_modules`, for code that is only required on a path a test run never takes. |
|
|
335
|
+
| `src/skill.ts` | The skills this package ships, and installing them into a project — what `bundle skill` runs. |
|
|
336
|
+
| `src/types/*.d.ts` | The `node:zlib` ZIP API and the `node:vfs` provider registry, neither of which `@types/node` carries yet. |
|
|
337
|
+
| `tools/observe.ts` | Drives the CLI through a recording mount of the package root, for the build's cross-check. |
|
|
338
|
+
| `tools/pack.ts` | Builds `build/cli.bundle`: computes the member list, checks it against an observation run, and writes the archive. |
|
|
339
|
+
| `tools/prepublish.ts` | The gate on `npm publish` — the signed CLI must exist, verify, and match a build of the current tree. |
|
|
340
|
+
| `test/*.test.ts` | 145 tests: the format, the archive, the two providers, the API, the CLI, the SEA, the skills, and the published package's own shape. |
|
|
341
|
+
| `shell-base` | The launcher prefix: two lines of `sh` that `exec node --no-warnings --experimental-vfs --vfs-load="$0" -- "$@"`. |
|
|
342
|
+
| `certs/` | A self-signed test PKI (root CA + leaf, `gen.sh`) used to sign and trust the demo archives offline. |
|
|
343
|
+
| `skills/audit-bundle/` | The audit skill: verify → extract → security-review every file. |
|
|
344
|
+
|
|
345
|
+
### The exports
|
|
346
|
+
|
|
347
|
+
```jsonc
|
|
348
|
+
{
|
|
349
|
+
".": "./dist/index.js", // create / sign / verify / inspect / run, from code
|
|
350
|
+
"./register": "./dist/register.js", // -r preload: mount only what is signed
|
|
351
|
+
"./record": "./dist/record.js", // -r preload: write down what a run reads
|
|
352
|
+
"./sea": "./dist/sea.js", // build a verifying runtime, with or without an app
|
|
353
|
+
"./launch": "./dist/launch.js", // verify a container, mount it, run it
|
|
354
|
+
"./provider": "./dist/provider.js", // the verifying provider, and register(options)
|
|
355
|
+
"./recorder": "./dist/recorder.js", // the recording provider, and recording(Base, manifest)
|
|
356
|
+
"./cli": "./dist/cli.js", // main(argv, io) -> exit code
|
|
357
|
+
"./manifest": "./dist/manifest.js", // the format on its own
|
|
358
|
+
"./archive": "./dist/archive.js",
|
|
359
|
+
"./files": "./dist/files.js",
|
|
360
|
+
"./skill": "./dist/skill.js",
|
|
361
|
+
"./sigstore": "./dist/sigstore.js",
|
|
362
|
+
"./oidc": "./dist/oidc.js"
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The package root deliberately does **not** re-export the two providers. Importing either
|
|
367
|
+
needs `node:vfs`, which exists only under `--experimental-vfs`, and creating or verifying an
|
|
368
|
+
archive does not — so `import '@pipobscure/bundle'` must not drag that requirement in. That
|
|
369
|
+
is what the separate entry points are for, and there is a test that holds the line.
|
|
370
|
+
|
|
371
|
+
### The scripts (`package.json`)
|
|
372
|
+
|
|
373
|
+
The four steps are four scripts, each runnable on its own, so the order is something you
|
|
374
|
+
can see rather than something the README asserts.
|
|
375
|
+
|
|
376
|
+
```jsonc
|
|
377
|
+
"build": "tsc && chmod +x dist/main.js",
|
|
378
|
+
// TypeScript to ESM in dist/, with .d.ts and declaration maps beside it.
|
|
379
|
+
|
|
380
|
+
// --- 1. observe -------------------------------------------------------------
|
|
381
|
+
"manifest:cli": "node --experimental-vfs tools/manifest.ts",
|
|
382
|
+
// Writes build/cli.manifest: the file list, from a computed dependency closure
|
|
383
|
+
// cross-checked against a recording run of the CLI. Anything the run reads that the
|
|
384
|
+
// closure missed stops the build.
|
|
385
|
+
|
|
386
|
+
// --- 2. create --------------------------------------------------------------
|
|
387
|
+
"pack:cli": "node dist/main.js create --base . --files build/cli.manifest --output build/cli.bundle",
|
|
388
|
+
// Plainly the CLI, over the list step 1 produced. Unsigned.
|
|
389
|
+
|
|
390
|
+
// --- 3. audit ---------------------------------------------------------------
|
|
391
|
+
"baseline:cli": "node --experimental-vfs tools/baseline.ts --allow-missing",
|
|
392
|
+
// Fetches the currently published bundle and verifies it, to review the new one
|
|
393
|
+
// *against*. The first release has none, and then the audit reviews everything.
|
|
394
|
+
"audit:cli": "node dist/main.js audit --baseline build/baseline.bundle …",
|
|
395
|
+
// Reports the archive's sha256, its member count and what changed against the baseline,
|
|
396
|
+
// then prints the exact skill invocation. The review needs judgement, so no script
|
|
397
|
+
// performs it.
|
|
398
|
+
"approve:cli": "node dist/main.js audit --approve …",
|
|
399
|
+
// Record a clean verdict reached by a person instead of by the skill.
|
|
400
|
+
|
|
401
|
+
// --- 4. sign ----------------------------------------------------------------
|
|
402
|
+
"sign:cli": "node dist/main.js audit --check … && node dist/main.js sign --launcher --output bundle.run build/cli.bundle",
|
|
403
|
+
// The gate runs first and exits non-zero without a clean verdict pinned to these bytes.
|
|
404
|
+
// Then sigstore — CI identity if there is one, otherwise a GitHub sign-in.
|
|
405
|
+
"sign:cli:local": "… --check && node dist/main.js sign --key build/certs/leaf.key --chain build/certs/chain.pem …",
|
|
406
|
+
// The same against the test PKI, for working offline.
|
|
407
|
+
|
|
408
|
+
"release:cli": "npm run build && npm run manifest:cli && npm run pack:cli && npm run baseline:cli && npm run audit:cli",
|
|
409
|
+
// Steps 1-3, stopping at the gate. Step 4 is deliberately not chained on: it needs a
|
|
410
|
+
// verdict, and a verdict is not a script's to give.
|
|
411
|
+
|
|
412
|
+
"verify:cli": "node dist/main.js verify bundle.run",
|
|
413
|
+
"trust": "node dist/main.js trust",
|
|
414
|
+
// Refresh the sigstore trust root (over TUF) into the local cache. Verification never
|
|
415
|
+
// reaches for the network, so this is the explicit step that feeds it.
|
|
416
|
+
|
|
417
|
+
"test": "npm run build && npm run typecheck && node --experimental-vfs --test test/*.test.ts",
|
|
418
|
+
"prepublishOnly": "node --experimental-vfs tools/prepublish.ts"
|
|
419
|
+
// Refuses to publish a package whose signed CLI is missing, unsigned, or stale.
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**The gate.** `bundle audit --check` is what makes step 3 a step rather than a
|
|
423
|
+
suggestion. It reads a JSON verdict the skill writes — `verdict: "pass" | "fail"`, the
|
|
424
|
+
findings, the **sha256 of the archive**, and the baseline's sha256 when the review was a
|
|
425
|
+
diff — and refuses unless the verdict passed *and* pins the bytes on disk. That pin is the
|
|
426
|
+
whole mechanism: an approval that could be carried to a later build is not an approval of
|
|
427
|
+
anything, so rebuilding invalidates it. A verdict naming a *different* baseline is refused
|
|
428
|
+
the same way; one naming none is accepted as a full review, with a note saying so.
|
|
429
|
+
|
|
430
|
+
```
|
|
431
|
+
$ npm run sign:cli
|
|
432
|
+
error: build/cli.bundle has not been audited — there is no verdict at build/cli.audit.json.
|
|
433
|
+
run 'npm run audit:cli' to see how
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
There is deliberately no environment variable that turns it off: a switch like that gets set
|
|
437
|
+
in CI once and never unset. It is a command you choose to put in your chain, and if you do
|
|
438
|
+
not want it, you do not put it there. That is the publisher's own gate, not a runtime policy
|
|
439
|
+
— the same reason `--identity` is something the verifier chooses rather than something the
|
|
440
|
+
format imposes.
|
|
441
|
+
|
|
442
|
+
Note the shape of the pipeline: **`create` once, `sign` many times.** `create` never needs a
|
|
443
|
+
key, and the archive it writes is the single input to every signed artifact. Drop `--root`
|
|
444
|
+
from `verify` to see how the same archive reads when its certificate isn't trusted; drop
|
|
445
|
+
`--prefix` from `sign` to produce a plain archive instead of a self-executing container.
|
|
446
|
+
|
|
447
|
+
`create` still accepts `--key`/`--chain` to sign at build time, which is convenient when you
|
|
448
|
+
only ever want one shape. It cannot be used with sigstore: a Fulcio certificate is bound to
|
|
449
|
+
an identity you have to authenticate for, so that path goes through `sign`.
|
|
450
|
+
|
|
451
|
+
### The three artifacts, and how each runs itself
|
|
452
|
+
|
|
453
|
+
All three come from `sign`, from one unsigned archive, differing only in the `--prefix` they
|
|
454
|
+
are re-emitted behind.
|
|
455
|
+
|
|
456
|
+
**The plain signed archive (needs Node and the preload).**
|
|
457
|
+
No prefix at all: just the ZIP, with its per-member digests, its `AUTHORITY.PEM` manifest
|
|
458
|
+
and the whole-file signature in the EOCD comment. It is run by mounting it:
|
|
459
|
+
|
|
460
|
+
```sh
|
|
461
|
+
node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle -- <args>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
The preload registers the provider; `--vfs-load` hands it the archive; the provider verifies
|
|
465
|
+
the signature and the chain **before** returning a filesystem, so an archive that fails is
|
|
466
|
+
never mounted and the entry point never runs. This is the mode where the *runtime* enforces
|
|
467
|
+
the signature rather than the application checking itself — the application needs no boot
|
|
468
|
+
code of its own at all. (The `--` matters: without it node claims any argument that looks
|
|
469
|
+
like one of its own flags, and the application never sees it.)
|
|
470
|
+
|
|
471
|
+
**`app.run` — the shebang archive (the same ZIP plus a one-line header; needs Node installed).**
|
|
472
|
+
It is the `shell-base` prefix — `#!/bin/sh` and one `exec node … --vfs-load="$0" -- "$@"`
|
|
473
|
+
line — followed by the ZIP. `exec` replaces the shell before it reads past that line, so the
|
|
474
|
+
archive bytes are never parsed as script; `"$0"` is the file's own path, so it mounts
|
|
475
|
+
**itself** and its `package.json` main becomes the entry point. The archive's `baseOffset`
|
|
476
|
+
was seeded past the prefix, so it stays a valid ZIP even though it doesn't start at byte 0.
|
|
477
|
+
A whole application in a file you can email — provided the recipient has a compatible Node.
|
|
478
|
+
|
|
479
|
+
> The obvious prefix is `#!/usr/bin/env -S node … --vfs-load`, letting the kernel's appended
|
|
480
|
+
> path become the trailing flag's value. That is prettier and it works, but the user's
|
|
481
|
+
> arguments land after that path with nowhere to put a `--`, so every dash-leading argument
|
|
482
|
+
> goes to node rather than to the program — `app.run --help` prints node's help. The shell
|
|
483
|
+
> line exists to place that `--`.
|
|
484
|
+
|
|
485
|
+
> **Note:** this needs a Node whose provider selection recognizes a ZIP by locating its
|
|
486
|
+
> end-of-central-directory record rather than by sniffing `PK\x03\x04` at byte 0 — a
|
|
487
|
+
> prefixed container by construction has no `PK` at byte 0, and the leading-bytes test
|
|
488
|
+
> rejected it with `ERR_VFS_INVALID_TARGET` before anything else happened. The bundle
|
|
489
|
+
> provider never had that blind spot (it always scanned from the tail), so
|
|
490
|
+
> `node -r @pipobscure/bundle/register --vfs-load=app.run` mounts and verifies
|
|
491
|
+
> the same file either way.
|
|
492
|
+
>
|
|
493
|
+
> The shebang launcher runs the archive *without* the verifying provider — the kernel gives
|
|
494
|
+
> it no preload flag to carry one. It is the convenient shape, not the enforcing one. For a
|
|
495
|
+
> container that gates itself, use the SEA below.
|
|
496
|
+
|
|
497
|
+
**The native executable (~155 MB; needs nothing).**
|
|
498
|
+
`bundle sea` produces it: a Node runtime whose SEA blob carries this package as a mounted
|
|
499
|
+
asset, with the application appended as a signed archive. Running it verifies the whole
|
|
500
|
+
file — runtime, verifier and application alike, since the signature covers all of it — and
|
|
501
|
+
only then mounts the archive and runs what is inside. No Node on the target, no
|
|
502
|
+
`node_modules`, no extraction to disk. See [Self-verifying the SEA](#self-verifying-the-sea).
|
|
503
|
+
|
|
504
|
+
Same application, same archive format, three shapes — one where the **runtime** enforces
|
|
505
|
+
the signature (`.bundle`), one optimizing for **size** (reuse the user's Node), one for
|
|
506
|
+
**self-containment** (bring your own Node). And, because the prefix is chosen at *signing*
|
|
507
|
+
time rather than at build time, all three come from one `create` and differ by one flag.
|
|
508
|
+
|
|
509
|
+
### Try it
|
|
510
|
+
|
|
511
|
+
```sh
|
|
512
|
+
npm install
|
|
513
|
+
npm run build
|
|
514
|
+
|
|
515
|
+
# 1. observe what a run reads.
|
|
516
|
+
BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
|
|
517
|
+
-r ./dist/record.js --vfs-load=./some/app > /dev/null
|
|
518
|
+
|
|
519
|
+
# 2. archive exactly that, unsigned.
|
|
520
|
+
node dist/main.js create --base ./some/app --files app.manifest --output app.bundle
|
|
521
|
+
node dist/main.js verify app.bundle # -> UNSIGNED
|
|
522
|
+
|
|
523
|
+
# 3. audit it, before putting your name on it.
|
|
524
|
+
node dist/main.js skill # install the skill, once per project
|
|
525
|
+
claude "/audit-bundle app.bundle" # or however you drive Claude Code
|
|
526
|
+
|
|
527
|
+
# 4. sign it — offline here, against the repository's test PKI.
|
|
528
|
+
node dist/main.js sign --key build/certs/leaf.key --chain build/certs/chain.pem \
|
|
529
|
+
--output app.signed.bundle app.bundle
|
|
530
|
+
node dist/main.js verify --root build/certs/root.pem app.signed.bundle # -> VALID
|
|
531
|
+
node dist/main.js verify app.signed.bundle # -> VALID (UNTRUSTED)
|
|
532
|
+
|
|
533
|
+
# Run it through the verifying mount.
|
|
534
|
+
node dist/main.js run --root build/certs/root.pem app.signed.bundle -- <args>
|
|
535
|
+
node --experimental-vfs -r ./dist/register.js --vfs-load=app.signed.bundle
|
|
536
|
+
# refuses: the test root is trusted by nothing
|
|
537
|
+
|
|
538
|
+
# The same archive behind a shebang, and inside a self-validating executable.
|
|
539
|
+
node dist/main.js sign --key build/certs/leaf.key --chain build/certs/chain.pem \
|
|
540
|
+
--prefix shell-base --output app.run app.bundle
|
|
541
|
+
node dist/main.js sea --key build/certs/leaf.key --chain build/certs/chain.pem \
|
|
542
|
+
--root build/certs/root.pem --output app.sea app.bundle
|
|
543
|
+
./app.sea <args> # verifies itself, then runs
|
|
544
|
+
|
|
545
|
+
npm test # 145 tests: sign, verify, mount, run, SEA, the gate, and every refusal
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Building the tool the way the tool says to build things — the same four steps:
|
|
549
|
+
|
|
550
|
+
```sh
|
|
551
|
+
npm run release:cli # 1-3: observe, pack, fetch the baseline, stop at the gate
|
|
552
|
+
# -> build/cli.manifest, build/cli.bundle (904 members)
|
|
553
|
+
|
|
554
|
+
npm run sign:cli:local # 4: refuses, because nothing has been audited yet
|
|
555
|
+
BUNDLE_AUDIT_VERDICT=build/cli.audit.json claude "/audit-bundle build/cli.bundle"
|
|
556
|
+
npm run sign:cli:local # 4: now allowed -> bundle.run
|
|
557
|
+
|
|
558
|
+
./bundle.run --help # the artifact runs itself; this is what npm links as `bundle`
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
To sign through sigstore instead — this opens a browser, or uses the CI identity when there
|
|
562
|
+
is one:
|
|
563
|
+
|
|
564
|
+
```sh
|
|
565
|
+
npm run trust # fetch the sigstore trust root, once
|
|
566
|
+
npm run sign:cli # -> bundle.run, signed by whoever you signed in as
|
|
567
|
+
npm run verify:cli # -> VALID, with the identity that signed it
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
A launcher archive can verify **itself**: `--vfs-load` leaves the container's own path
|
|
571
|
+
readable, so from inside, `process.argv[1]` is the real file and reading it yields the raw
|
|
572
|
+
bytes rather than the mounted tree. `./app.run verify app.run` works.
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Signing and verification
|
|
577
|
+
|
|
578
|
+
A single-file application is only as trustworthy as the bytes inside it. Building on the ZIP
|
|
579
|
+
toolkit, every archive this repo produces is protected by a **staged** scheme: one hash covers
|
|
580
|
+
the **whole file** (prefix included), the leaf certificate signs *that hash*, and both are
|
|
581
|
+
written into the archive's end-of-central-directory comment. Each **member** additionally
|
|
582
|
+
carries its own content digest. The staging is the point — a verifier can prove the bytes are
|
|
583
|
+
intact *before* it commits to anything (a cheap, cert-free gate), and only then spend a
|
|
584
|
+
certificate check. This is an application-level feature — it uses `node:zlib`'s
|
|
585
|
+
`ZipEntry`/`X509Certificate` primitives; it is not a change to Node.
|
|
586
|
+
|
|
587
|
+
### The `AUTHORITY.PEM` manifest
|
|
588
|
+
|
|
589
|
+
**The `AUTHORITY.PEM` manifest** is a normal archive entry that declares the algorithms and
|
|
590
|
+
carries the certificate chain — the signing authority. Its name is a real, extractable
|
|
591
|
+
filename, so a plain zip utility can pull it out for auditing:
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
!manifest 2 magic + format version
|
|
595
|
+
!hash sha256 digest used for the whole-file hash and member digests
|
|
596
|
+
!sign sha256 digest the signature (over that hash) uses
|
|
597
|
+
(blank line — present only when signed)
|
|
598
|
+
-----BEGIN CERTIFICATE----- full PEM chain, leaf first, embedded so a
|
|
599
|
+
… verifier is self-contained
|
|
600
|
+
-----END CERTIFICATE-----
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
**Every member** (every entry except the manifest) also records the hex digest of its own
|
|
604
|
+
content in its ZIP **entry comment**, computed with `!hash`.
|
|
605
|
+
|
|
606
|
+
### The whole-file hash, and a signature over it
|
|
607
|
+
|
|
608
|
+
One hash covers the *entire file* — the prepended launcher or Node/SEA binary, every member,
|
|
609
|
+
the complete central directory (member digests included) and the fixed part of the EOCD
|
|
610
|
+
record — up to but **excluding the EOCD's 2-byte comment-length field**. The EOCD must be the
|
|
611
|
+
last structure in the file, so the hashed region is simply everything before its trailing
|
|
612
|
+
comment. The leaf certificate then signs **that hash** (not the file), and the EOCD comment —
|
|
613
|
+
which the hash deliberately stops short of — records both:
|
|
614
|
+
|
|
615
|
+
```
|
|
616
|
+
SIGNED:<hash-of-region-hex>:<signature-hex>[:<NAME>=<value>]*
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Signing the hash rather than the file is what makes the stages cheap: a verifier hashes the
|
|
620
|
+
file once, matches it against `<hash>`, and can then check `<signature>` over that same hash
|
|
621
|
+
without touching the file again. Because the hash spans the whole file, nothing — a byte of
|
|
622
|
+
the runtime prefix, a member's bytes, a recorded digest in the central directory, the
|
|
623
|
+
algorithm lines, or the embedded chain — can be altered without changing it. The per-member
|
|
624
|
+
digests are that guarantee applied one file at a time, so an individual extracted member can
|
|
625
|
+
be checked on its own (and a member fetch can re-verify it).
|
|
626
|
+
|
|
627
|
+
The trailing `NAME=value` fields are the **unsigned-attribute region** every code-signing
|
|
628
|
+
scheme eventually grows. Anything obtained *after* the signature exists cannot be inside
|
|
629
|
+
what the signature covers, so it goes beside it instead — the same placement RFC 3161 gives
|
|
630
|
+
timestamp tokens in CMS `unsignedAttrs`, and Authenticode gives counter-signatures. Today
|
|
631
|
+
the only field is `SIGSTORE=`, carrying the transparency-log entry and timestamp that
|
|
632
|
+
establish *when* the archive was signed. Fields are optional and unknown ones are ignored,
|
|
633
|
+
so a two-field marker written before the grammar existed still parses.
|
|
634
|
+
|
|
635
|
+
### Signing with sigstore
|
|
636
|
+
|
|
637
|
+
`bundle sign` defaults to sigstore, which replaces "a signing key on someone's disk" with
|
|
638
|
+
"an identity you authenticate as". [Fulcio](https://github.com/sigstore/fulcio) issues a
|
|
639
|
+
certificate binding an OIDC identity to a keypair that exists only for the duration of the
|
|
640
|
+
command; there is no long-lived key to steal, because the certificate expires in about ten
|
|
641
|
+
minutes.
|
|
642
|
+
|
|
643
|
+
Where the identity comes from is a property of where the command runs, so it is not a flag
|
|
644
|
+
you have to remember:
|
|
645
|
+
|
|
646
|
+
| Where | What happens | The identity |
|
|
647
|
+
|---|---|---|
|
|
648
|
+
| GitHub Actions | `ACTIONS_ID_TOKEN_REQUEST_URL` is read directly (needs `permissions: id-token: write`) | the repository and workflow — `https://github.com/OWNER/REPO/.github/workflows/release.yml@refs/heads/main` |
|
|
649
|
+
| A workstation | a browser opens on sigstore's Dex, which redirects straight to GitHub | your email |
|
|
650
|
+
| A headless box | a device code to enter elsewhere | your email |
|
|
651
|
+
|
|
652
|
+
Override with `--flow ci\|browser\|device`, `--token <jwt>` to supply one yourself, or
|
|
653
|
+
`--connector google\|microsoft\|none` to sign in as something other than GitHub.
|
|
654
|
+
|
|
655
|
+
**The ordering problem.** `AUTHORITY.PEM` is a member, so it is inside the hashed region:
|
|
656
|
+
the certificate chain must be known *before* the hash exists. The signature must be made
|
|
657
|
+
*after*. Sigstore's own `BundleBuilder` does both in one call, which cannot work here — but
|
|
658
|
+
it does not have to, because a Fulcio certificate binds an *identity to a public key* and
|
|
659
|
+
says nothing about any message. So the two halves separate cleanly, and `src/sigstore.ts`
|
|
660
|
+
exposes them as a two-phase signer: sign in and get the certificate, then, once the archive
|
|
661
|
+
has been built around it and hashed, sign the hash and have that signature witnessed by
|
|
662
|
+
Rekor and the timestamp authority.
|
|
663
|
+
|
|
664
|
+
**Why the witnesses matter more here than usual.** A ten-minute certificate makes "is this
|
|
665
|
+
chain in date *now*?" the wrong question — it fails every archive older than lunchtime. The
|
|
666
|
+
right question is whether the certificate was valid *when the signature was made*, and
|
|
667
|
+
answering it needs a trustworthy assertion of when that was. That is exactly what the
|
|
668
|
+
transparency-log entry and the RFC 3161 token in the `SIGSTORE=` field provide, and it is
|
|
669
|
+
why a sigstore signature takes a different trust path from an ordinary one rather than an
|
|
670
|
+
additional check on the same path. `bundle sign` refuses to finish if neither witness could
|
|
671
|
+
be reached, because the result could never be verified again.
|
|
672
|
+
|
|
673
|
+
Two things are checked that the sigstore libraries do not check for you. The bundle must be
|
|
674
|
+
over *this* archive's hash — otherwise a valid bundle could be transplanted from another
|
|
675
|
+
archive — and the certificate it was verified against must be the same one `AUTHORITY.PEM`
|
|
676
|
+
names. Without the second, an attacker could pin a genuine bundle for their own identity to
|
|
677
|
+
an archive whose extractable manifest claims a different signer: the signature would check
|
|
678
|
+
out and the inspectable file would be a lie.
|
|
679
|
+
|
|
680
|
+
Verification never reaches for the network. The sigstore trust root is fetched by an
|
|
681
|
+
explicit `bundle trust` (over TUF — signed metadata with its own root of trust, not a plain
|
|
682
|
+
download) and cached; `verifySync()` then loads it synchronously, which it has to, because
|
|
683
|
+
the mount path decides whether to serve an archive before any of the program in it runs.
|
|
684
|
+
|
|
685
|
+
### The four verification states
|
|
686
|
+
|
|
687
|
+
`verify()` runs the stages in order and reports exactly one state:
|
|
688
|
+
|
|
689
|
+
| State | Meaning |
|
|
690
|
+
|-------|---------|
|
|
691
|
+
| **unsigned** | no `AUTHORITY.PEM` manifest, or no `SIGNED:…` marker in the EOCD comment |
|
|
692
|
+
| **invalid** | the recomputed hash doesn't match the recorded one, the signature doesn't verify over it, **or** a member's recorded digest doesn't match its content |
|
|
693
|
+
| **valid-untrusted** | hash + signature + digests are sound, but the certificate chain isn't anchored in the trust store — or, for a sigstore signature, the trust root is missing or the identity didn't match a required policy |
|
|
694
|
+
| **valid** | all of the above sound **and** the chain is trusted |
|
|
695
|
+
|
|
696
|
+
Note which side of the line the sigstore cases fall on. Not being *able* to check — no trust
|
|
697
|
+
root cached, the libraries absent — is `valid-untrusted`, not `invalid`: "I could not check"
|
|
698
|
+
and "this is forged" are different answers and conflating them is how people are trained to
|
|
699
|
+
click through warnings. A bundle that is present and fails to verify is `invalid`.
|
|
700
|
+
|
|
701
|
+
`verifySync()` is the implementation and `verify()` an `async` wrapper around it; both take
|
|
702
|
+
`{ extraRoots, now, deep, trustedRoot, identity, issuer }`. `identity` and `issuer` impose a
|
|
703
|
+
policy on a sigstore signature — "it must be *this* signer" — and a mismatch reports
|
|
704
|
+
`valid-untrusted`, since the signature is genuine and simply not the one demanded. `now` is
|
|
705
|
+
ignored on the sigstore path, which derives the signing time from the archive's own log
|
|
706
|
+
entry instead. With `deep: false` the member digests are checked for
|
|
707
|
+
*presence* but not recomputed — the right trade for a mount, which re-checks each member as
|
|
708
|
+
it is actually read (see below). The result also carries `hashAlg` and the `digests` map
|
|
709
|
+
read from the archive that was just hashed, so a caller that goes on to serve those members
|
|
710
|
+
checks them against what the signature covered rather than re-reading the comments later.
|
|
711
|
+
|
|
712
|
+
For a sigstore signature the result also carries `identity`, `issuer` and `signedAt`. Those
|
|
713
|
+
are the answer to "who signed this"; the certificate subject is an ephemeral Fulcio artifact
|
|
714
|
+
and says nothing useful, so `bundle verify` prints the identity instead when there is one.
|
|
715
|
+
|
|
716
|
+
The whole scheme is gated on the `SIGNED:` marker: only a signed archive is checked at all.
|
|
717
|
+
Because the hash covers the central directory, it fixes *which* members exist and every
|
|
718
|
+
member's digest, so editing *any* byte after signing yields **invalid**. Trust is evaluated
|
|
719
|
+
against the system CA store **plus** `NODE_EXTRA_CA_CERTS` (and any extra roots passed to
|
|
720
|
+
`verify`), so the trusted path is testable without touching the OS store.
|
|
721
|
+
|
|
722
|
+
### The library
|
|
723
|
+
|
|
724
|
+
Every operation the CLI has is an export, and the CLI is a `parseArgs` wrapper over them:
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
import {
|
|
728
|
+
createBundle, signBundle, verifyBundle, inspectBundle, runBundle, fileSigner,
|
|
729
|
+
} from '@pipobscure/bundle';
|
|
730
|
+
|
|
731
|
+
// Build unsigned. The archive this writes is the single input to every shape you ship.
|
|
732
|
+
await createBundle({ base: 'app/', files, output: 'app.bundle' });
|
|
733
|
+
|
|
734
|
+
// Sign it — once per shape, each correctly offset and each signed over its own bytes.
|
|
735
|
+
await signBundle({ source: 'app.bundle', output: 'app.signed.bundle', signer });
|
|
736
|
+
await signBundle({ source: 'app.bundle', output: 'app.run', prefix: 'shell-base', signer });
|
|
737
|
+
|
|
738
|
+
// Ask what it claims, and then whether any of it is true.
|
|
739
|
+
const { members, signed, hash } = inspectBundle('app.run');
|
|
740
|
+
const { state, reason, identity } = await verifyBundle('app.run', { roots: ['root.pem'] });
|
|
741
|
+
|
|
742
|
+
// Mount it through the verifying provider, in a child process, and run it.
|
|
743
|
+
const { status } = runBundle('app.signed.bundle', { roots: ['root.pem'], args: ['--help'] });
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
The layer underneath is exported too, for callers assembling members themselves rather than
|
|
747
|
+
from a directory:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
import { buildManifest, verifySync } from '@pipobscure/bundle/manifest';
|
|
751
|
+
import { bundle, rebundle, createArchive, keySigner } from '@pipobscure/bundle/archive';
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
A **signer** is just `{ chain, signAlg, sign(digest) }`: the chain goes into `AUTHORITY.PEM`
|
|
755
|
+
before hashing, and `sign()` is called after, with the finished hash, returning a signature
|
|
756
|
+
and any unsigned-attribute fields. `keySigner()` is the offline-CA implementation and
|
|
757
|
+
`@pipobscure/bundle/sigstore`'s `signer()` is the other one — `rebundle()` knows about
|
|
758
|
+
neither, which is the point. Writing a third (an HSM, a KMS, a corporate signing service) is
|
|
759
|
+
a matter of implementing those three properties.
|
|
760
|
+
|
|
761
|
+
Working out *what* to bundle has two answers, and the build here uses both. `@pipobscure/bundle/recorder`
|
|
762
|
+
observes a run; `@pipobscure/bundle/files` computes a closure:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
import { moduleFiles } from '@pipobscure/bundle/files';
|
|
766
|
+
|
|
767
|
+
const files = moduleFiles({
|
|
768
|
+
base: '.',
|
|
769
|
+
files: ['package.json'],
|
|
770
|
+
dirs: ['dist'],
|
|
771
|
+
dependencies: ['@sigstore/verify'], // and everything they depend on, transitively
|
|
772
|
+
filter: (name) => !name.endsWith('.map'),
|
|
773
|
+
});
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
Observation is the honest answer — it produces exactly the set that was used — but it has one
|
|
777
|
+
blind spot that matters for a verifier: code loaded lazily on a path the run never took.
|
|
778
|
+
`sigstore.ts` requires `@sigstore/verify` only when it meets an archive with a `SIGSTORE=`
|
|
779
|
+
field, so a build that signs with a local key never touches it, and the resulting bundle
|
|
780
|
+
would be unable to check a sigstore signature it later met. So `tools/pack.ts` computes the
|
|
781
|
+
closure for completeness and uses the observation run as the *check* on it: anything read
|
|
782
|
+
that the closure missed is a hole, and the build stops.
|
|
783
|
+
|
|
784
|
+
`./provider` and `./recorder` are deliberately *not* re-exported from the package root:
|
|
785
|
+
importing them needs `node:vfs`, which only exists under `--experimental-vfs`, while
|
|
786
|
+
creating and verifying archives does not. `./sigstore` is namespaced rather than flattened
|
|
787
|
+
for a different reason: it is a signer implementation, not part of the archive format, and
|
|
788
|
+
an archive signed against an ordinary CA verifies with nothing but `node:crypto`. The
|
|
789
|
+
sigstore libraries' absence degrades a sigstore verification to `valid-untrusted` rather
|
|
790
|
+
than breaking it.
|
|
791
|
+
|
|
792
|
+
### Running only what is signed — the `.bundle` provider
|
|
793
|
+
|
|
794
|
+
`src/provider.ts` is where verification stops being something an application does to itself
|
|
795
|
+
and becomes a property of the mount. It is a `ZipProvider` subclass, registered ahead of the
|
|
796
|
+
built-in one, and it gates in two places:
|
|
797
|
+
|
|
798
|
+
**At mount.** `open()` recomputes the whole-file hash, checks the signature over it against
|
|
799
|
+
the leaf certificate in `AUTHORITY.PEM`, and anchors the chain in the trust store. Anything
|
|
800
|
+
short of `valid` throws `ERR_BUNDLE_UNTRUSTED` out of provider selection, which is *before*
|
|
801
|
+
the entry point is resolved — an archive that fails does not become a filesystem, so there
|
|
802
|
+
is no window in which its code could run.
|
|
803
|
+
|
|
804
|
+
**At fetch.** Every member is hashed as it is read and compared with the digest recorded for
|
|
805
|
+
it, and only then handed over; a mismatch throws `ERR_BUNDLE_INTEGRITY` instead of returning
|
|
806
|
+
content. The mount-time hash already covers every member's bytes — but it covers them *as
|
|
807
|
+
they were when the file was hashed*, and a `ZipFile` reads members lazily from an open file
|
|
808
|
+
descriptor. Rewriting the archive underneath a running program would otherwise serve the new
|
|
809
|
+
bytes unchecked. Verified content is kept in memory (members are an application's own files,
|
|
810
|
+
not the runtime), so each member is read and hashed at most once, and what later reads see is
|
|
811
|
+
the copy that was verified rather than a fresh read of the file.
|
|
812
|
+
|
|
813
|
+
A mounted archive is read-only regardless of how the underlying ZIP was opened: any write
|
|
814
|
+
would invalidate the signature the mount was granted on, so writes fail with `EROFS`.
|
|
815
|
+
|
|
816
|
+
Configure the preload through the environment, since `-r` takes no arguments:
|
|
817
|
+
|
|
818
|
+
| Variable | Effect |
|
|
819
|
+
|----------|--------|
|
|
820
|
+
| `BUNDLE_ROOTS` | Extra trusted roots, a path-delimiter-separated list of PEM files. |
|
|
821
|
+
| `BUNDLE_ALLOW_UNTRUSTED` | Accept a good signature whose chain is not anchored in the trust store. |
|
|
822
|
+
| `BUNDLE_IDENTITY` | Require this sigstore signing identity — a machine configured to run only releases from one workflow. |
|
|
823
|
+
| `BUNDLE_ISSUER` | Require this sigstore OIDC issuer. |
|
|
824
|
+
| `BUNDLE_SIGSTORE_ROOT` | The sigstore trust root to check against, instead of the cache `bundle trust` fills. |
|
|
825
|
+
|
|
826
|
+
Demanding an identity is a demand about *who signed this*, and only a sigstore signature
|
|
827
|
+
carries an answer. An archive signed against an ordinary CA makes no such claim, so it reads
|
|
828
|
+
as `valid-untrusted` under such a policy rather than passing — otherwise `BUNDLE_IDENTITY`
|
|
829
|
+
would be a no-op in exactly the deployment that relies on it.
|
|
830
|
+
|
|
831
|
+
For anything more, call `register()` yourself from a preload of your own:
|
|
832
|
+
|
|
833
|
+
```js
|
|
834
|
+
// my-preload.js — node --experimental-vfs -r ./my-preload.js --vfs-load=app.bundle
|
|
835
|
+
import { register } from '@pipobscure/bundle/provider';
|
|
836
|
+
|
|
837
|
+
register({
|
|
838
|
+
extensions: ['.bundle', '.app'], // claimed by name
|
|
839
|
+
claimSigned: true, // and anything carrying a SIGNED: marker, whatever it is called
|
|
840
|
+
roots: ['/etc/ssl/my-root.pem'], // PEM text or paths to PEM files
|
|
841
|
+
allowUntrusted: false,
|
|
842
|
+
deep: false, // recompute every digest at mount, not on fetch
|
|
843
|
+
identity: 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main',
|
|
844
|
+
issuer: 'https://token.actions.githubusercontent.com',
|
|
845
|
+
});
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
A preload runs under the CommonJS loader, so it must not contain a top-level `await` — but
|
|
849
|
+
ESM syntax is otherwise fine, and `--import` works as well as `-r`.
|
|
850
|
+
|
|
851
|
+
`bundle run <archive> [-- <args>]` is the same thing with the flags filled in: it re-execs
|
|
852
|
+
`node` with the preload and `--vfs-load`, so what runs is what the child's own bootstrap
|
|
853
|
+
verified.
|
|
854
|
+
|
|
855
|
+
### The verifying runtime, in two shapes
|
|
856
|
+
|
|
857
|
+
`bundle sea` builds a node runtime with this package inside it. Whether the result is an
|
|
858
|
+
application or a tool depends on nothing but what is behind it.
|
|
859
|
+
|
|
860
|
+
**With an archive appended, it is that application**, checking its own signature before it
|
|
861
|
+
runs anything:
|
|
862
|
+
|
|
863
|
+
```
|
|
864
|
+
[ node runtime | SEA blob: stub + the verifier, as a mounted archive ] [ app.bundle ]
|
|
865
|
+
\_______________________ the prefix, and part of the _______________/
|
|
866
|
+
\______________________ archive's signed region ______/
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
The application is an ordinary signed `.bundle` appended to a node binary — the same
|
|
870
|
+
`sign --prefix` that produces a shebang launcher. What makes the result self-validating is
|
|
871
|
+
that the whole-file hash covers the prefix too, so the runtime and the verifier inside it
|
|
872
|
+
are signed by the same signature that covers the application. There is nothing to check the
|
|
873
|
+
checker against, because the checker is inside what is checked.
|
|
874
|
+
|
|
875
|
+
**With nothing appended, it is a verifying node**: a runtime that takes an archive on its
|
|
876
|
+
command line, checks it, and runs it.
|
|
877
|
+
|
|
878
|
+
```sh
|
|
879
|
+
bundle sea --output node-verifying --root /etc/ssl/my-root.pem
|
|
880
|
+
./node-verifying ./my-app.zip --args --for --the --app
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
One runtime, any number of applications, none of them trusted until they verify — the shape
|
|
884
|
+
you want when the thing being distributed is *many* signed archives rather than one program.
|
|
885
|
+
The application sees the argv `--vfs-load` would have given it: the archive where a script
|
|
886
|
+
path goes, its own arguments from index 2 on, and none of the runtime's flags, which is why
|
|
887
|
+
everything after the archive belongs to the program.
|
|
888
|
+
|
|
889
|
+
The two are one binary, and it decides which it is by looking at its own tail: a signed
|
|
890
|
+
archive behind it runs that, nothing behind it takes one from the command line, and an
|
|
891
|
+
*unsigned* archive behind it is refused rather than quietly treated as neither. So a
|
|
892
|
+
verifying node built today becomes a self-validating executable tomorrow with nothing but
|
|
893
|
+
`bundle sign --prefix`, which is the same operation that puts a shebang in front of an
|
|
894
|
+
archive.
|
|
895
|
+
|
|
896
|
+
**A policy is baked in, or it is not.** `--root`, `--identity` and `--issuer` at build time
|
|
897
|
+
become the binary's own policy, and a binary built with one is **sealed**: it accepts no
|
|
898
|
+
policy from its command line, because a runtime that demands a signing identity is not one
|
|
899
|
+
whose user can ask it to stop. Built without, the same flags work and fall back to the
|
|
900
|
+
environment — one build, decided about later.
|
|
901
|
+
|
|
902
|
+
**The package rides inside as an archive node mounts for itself.** The stub runs before
|
|
903
|
+
anything is mounted, so it cannot import this package the ordinary way. It no longer has to:
|
|
904
|
+
`"useVfs": true` ([nodejs/node#65675](https://github.com/nodejs/node/pull/65675), released in
|
|
905
|
+
v26.9.0) with `"vfsArchive"` ([nodejs/node#65810](https://github.com/nodejs/node/pull/65810),
|
|
906
|
+
still open) embeds a ZIP in the executable and mounts it as the file system the main script
|
|
907
|
+
runs from. The stub is injected at the root of that mount, so
|
|
908
|
+
requiring the launcher is a relative path and nothing else.
|
|
909
|
+
|
|
910
|
+
That replaced the userland version of the same idea: the verifier bundle as a raw SEA asset,
|
|
911
|
+
copied into a `ZipBuffer` and mounted by hand in a stub that had to know how. Two mounts
|
|
912
|
+
became one, `getRawAsset` and the copy went with it, and the piece of this system that no
|
|
913
|
+
test can exercise from source — the generated stub — went from twenty lines to three.
|
|
914
|
+
|
|
915
|
+
The startup, in order:
|
|
916
|
+
|
|
917
|
+
1. **verify** — the verifying provider from `./provider` recomputes the whole-file hash over
|
|
918
|
+
the container (for a self-validating executable that is `process.execPath`, runtime and
|
|
919
|
+
all; for a verifying node it is the archive named on the command line), checks the
|
|
920
|
+
signature over it against the chain in `AUTHORITY.PEM`, and anchors that chain. Anything
|
|
921
|
+
short of acceptable exits non-zero here, before the archive is a filesystem.
|
|
922
|
+
2. **mount** — only then does the archive become the application's file tree, and `__filename`,
|
|
923
|
+
`import.meta.dirname`, relative imports and `node_modules` all resolve inside it.
|
|
924
|
+
3. **run** — the archive's `package.json` `main`, `require()`d or `import()`ed as its `type`
|
|
925
|
+
and extension say.
|
|
926
|
+
|
|
927
|
+
Because the mount is the *verifying* provider rather than a plain `ZipProvider`, this is not
|
|
928
|
+
merely a signature check at startup: every member is re-hashed against its signed digest as
|
|
929
|
+
it is first read, for the whole life of the process.
|
|
930
|
+
|
|
931
|
+
**What runs before the check.** The stub and the verifier execute before the signature has
|
|
932
|
+
been verified. That is not a hole so much as the place where trust has to start: both live
|
|
933
|
+
inside the prefix, which is inside the hashed region, so tampering with either invalidates
|
|
934
|
+
the signature over the application — and an attacker who could rewrite the executable's own
|
|
935
|
+
runtime could equally rewrite a verifier that ran first. The application never runs until
|
|
936
|
+
the check passes.
|
|
937
|
+
|
|
938
|
+
Building one, and configuring what the finished binary will accept:
|
|
939
|
+
|
|
940
|
+
```sh
|
|
941
|
+
bundle sea --output app.sea \
|
|
942
|
+
--root /etc/ssl/my-root.pem \
|
|
943
|
+
--identity 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main' \
|
|
944
|
+
--issuer 'https://token.actions.githubusercontent.com' \
|
|
945
|
+
app.bundle
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
Those become the executable's own policy, baked into the stub — which is the point, since a
|
|
949
|
+
binary run by its own name has no flags and no preload to configure it. Leave them off and
|
|
950
|
+
the policy comes from `BUNDLE_ROOTS` / `BUNDLE_IDENTITY` / `BUNDLE_ALLOW_UNTRUSTED` in the
|
|
951
|
+
environment instead, so one build can be decided about later. From code:
|
|
952
|
+
|
|
953
|
+
```ts
|
|
954
|
+
import { buildSea, createSeaBase } from '@pipobscure/bundle/sea';
|
|
955
|
+
|
|
956
|
+
// The base is the expensive half (a ~155 MB copy of node); build it once and reuse it.
|
|
957
|
+
await createSeaBase({ output: 'sea-base', bootstrap: { roots: ['root.pem'] } });
|
|
958
|
+
await buildSea({ app: 'app.bundle', output: 'app.sea', base: 'sea-base', signer });
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
An application can also ask about its own provenance without mounting anything:
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
import { verifySelf } from '@pipobscure/bundle/sea';
|
|
965
|
+
|
|
966
|
+
const { state, identity, signedAt } = verifySelf();
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
All of it runs through one entry point, `@pipobscure/bundle/launch`, which is where the
|
|
970
|
+
verify-mount-run path lives now that three callers share it: `runSelf()` for an executable
|
|
971
|
+
with an archive behind it, `main(argv)` for a runtime that is handed one, and `run(container)`
|
|
972
|
+
for a process that already exists. They differ in which container they are given and in what
|
|
973
|
+
`process.argv` should look like afterwards; everything else — the refusal, the mount, the
|
|
974
|
+
choice between `require` and `import` — is the same code.
|
|
975
|
+
|
|
976
|
+
By contrast, the shebang launcher has no pre-mount stage of its own, so `app.run` executed
|
|
977
|
+
directly does not self-verify — the kernel gives it no preload flag to carry a provider, and
|
|
978
|
+
it hands straight off to the app. Mounting it with the provider preloaded
|
|
979
|
+
(`node -r @pipobscure/bundle/register --vfs-load=app.run`) is what closes that
|
|
980
|
+
gap, and is the one route by which `app.run` runs verified at all.
|
|
981
|
+
|
|
982
|
+
---
|
|
983
|
+
|
|
984
|
+
## Recording the manifest
|
|
985
|
+
|
|
986
|
+
Building an archive needs a file list, and the honest way to get one is to run the
|
|
987
|
+
application and write down what it read. That used to be `--vfs-manifest=<file>`, a flag
|
|
988
|
+
that poked an observer slot inside `node:vfs`. With mounting reduced to `--vfs-load` and
|
|
989
|
+
provider selection the one place a mount can be influenced, the same job is better done by
|
|
990
|
+
a **provider** — which is what `src/recorder.ts` is:
|
|
991
|
+
|
|
992
|
+
```sh
|
|
993
|
+
BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
|
|
994
|
+
-r @pipobscure/bundle/record --vfs-load=./app
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
`recording(Base, manifest)` wraps a provider *class*, leaving its constructor signature
|
|
998
|
+
alone, and records every read that passes through it; `register()` installs
|
|
999
|
+
`recording(RealFSProvider)` for directory mounts. Paths are appended, VFS-relative and one
|
|
1000
|
+
per line, **as they are read** rather than buffered until exit, so a killed process still
|
|
1001
|
+
leaves a usable list — and worker threads, which inherit `execArgv` and so run this preload
|
|
1002
|
+
too, append to the same file instead of truncating it (only the main thread starts a fresh
|
|
1003
|
+
one; `O_APPEND` writes don't interleave).
|
|
1004
|
+
|
|
1005
|
+
It is a superset of what the flag recorded, in one respect. The flag hooked
|
|
1006
|
+
`readFile()`/`readFileSync()`, where the module loader and ordinary `fs` reads converge —
|
|
1007
|
+
but a `createReadStream()` goes through `open()` and a handle, and was never recorded. Here
|
|
1008
|
+
read-only `open()`s count too: a file that was opened but never read is harmless in a
|
|
1009
|
+
bundle, a streamed file missing from one is not.
|
|
1010
|
+
|
|
1011
|
+
Because it wraps a *class*, it composes — this records what a signed archive's own code
|
|
1012
|
+
touches at run time:
|
|
1013
|
+
|
|
1014
|
+
```ts
|
|
1015
|
+
// my-preload.js
|
|
1016
|
+
import { BundleProvider } from '@pipobscure/bundle/provider';
|
|
1017
|
+
import { recording, Manifest } from '@pipobscure/bundle/recorder';
|
|
1018
|
+
|
|
1019
|
+
const Recording = recording(BundleProvider, new Manifest('reads.txt'));
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
One thing it does not do: it records per *mount*, not per process, so several mounted
|
|
1023
|
+
directories merge into one list — the `--vfs-manifest` flag only ever supported a single
|
|
1024
|
+
directory target, so this is new ground rather than a regression.
|
|
1025
|
+
|
|
1026
|
+
An earlier constraint here has since gone away, and it is worth recording because it shaped
|
|
1027
|
+
the code. Provider selection once applied only when `--vfs-mount` was handed a *file*, with
|
|
1028
|
+
a directory mounted by node's own `RealFSProvider` without asking — so a recording mount
|
|
1029
|
+
could not be installed from a preload at all, and `tools/observe.ts` makes the mount itself
|
|
1030
|
+
and resolves the entry point against the path it is given back.
|
|
1031
|
+
[nodejs/node#65748](https://github.com/nodejs/node/pull/65748) offers registered providers
|
|
1032
|
+
directories as well as files, which is what the `-r @pipobscure/bundle/record` line above now
|
|
1033
|
+
relies on. The runner is still the shape to copy when you want the mount point in hand — but
|
|
1034
|
+
it is a choice now, not the only route.
|
|
1035
|
+
|
|
1036
|
+
---
|
|
1037
|
+
|
|
1038
|
+
## Auditing a bundle
|
|
1039
|
+
|
|
1040
|
+
A signature answers *who produced these bytes*. It does not answer *are these bytes safe*.
|
|
1041
|
+
Those are different questions, and the second one is the one the recent supply-chain
|
|
1042
|
+
attacks actually exploited: every significant npm compromise of recent years shipped a
|
|
1043
|
+
correctly published, correctly signed package from a legitimately compromised account.
|
|
1044
|
+
Provenance would have confirmed it came from the real maintainer, and been useless.
|
|
1045
|
+
|
|
1046
|
+
What makes the second question tractable here is that a bundle is a **closed set**. Nothing
|
|
1047
|
+
resolves later, nothing is fetched at install, no lifecycle script pulls in more code.
|
|
1048
|
+
Unlike a review of a dependency tree, a review over a bundle can be complete.
|
|
1049
|
+
|
|
1050
|
+
`skills/audit-bundle/` is a [Claude Code](https://claude.com/claude-code) skill that
|
|
1051
|
+
does it in three phases:
|
|
1052
|
+
|
|
1053
|
+
1. **Verify** — run `bundle verify --json` and report the state and signing identity. This
|
|
1054
|
+
phase gates the rest and is not skippable: it stops on `invalid`, and continues with a
|
|
1055
|
+
prominent warning on `unsigned` or `valid-untrusted`, since reviewing an archive you
|
|
1056
|
+
cannot place is precisely the useful case. An audit that silently reviewed a tampered
|
|
1057
|
+
archive would be worse than no audit.
|
|
1058
|
+
2. **Extract** — `unzip` to a scratch directory. It is a real ZIP, so this needs no special
|
|
1059
|
+
tooling, and the extracted file list is reconciled against the signed member list.
|
|
1060
|
+
3. **Review** — read every file, hunting what those attacks did: install and load-time
|
|
1061
|
+
hooks, obfuscated or encoded payloads, outbound network calls from modules with no
|
|
1062
|
+
reason to make them, environment and credential reads, CI token and cloud metadata
|
|
1063
|
+
endpoints, `child_process`/`eval`/dynamic `require`, and members nothing references.
|
|
1064
|
+
|
|
1065
|
+
```
|
|
1066
|
+
/audit-bundle app.run
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
Claude Code discovers skills under `.claude/skills/`, and the skill belongs next to whoever
|
|
1070
|
+
is about to run an archive rather than in this repository — so the CLI writes it out:
|
|
1071
|
+
|
|
1072
|
+
```sh
|
|
1073
|
+
bundle skill # -> .claude/skills/audit-bundle/SKILL.md
|
|
1074
|
+
bundle skill --list # what this package carries
|
|
1075
|
+
bundle skill --dir ~/.claude/skills # somewhere else
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
It never overwrites a file that is already there (`--force` if you mean to), so local edits
|
|
1079
|
+
survive an upgrade. The same thing from code, if you are wiring it into a setup script:
|
|
1080
|
+
|
|
1081
|
+
```ts
|
|
1082
|
+
import { installSkill, skills } from '@pipobscure/bundle';
|
|
1083
|
+
|
|
1084
|
+
const { written, skipped } = installSkill('audit-bundle', { dir: '.claude/skills' });
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
It also has a diff mode: verify and extract two archives, review only what changed against a
|
|
1088
|
+
previously approved one, and call out any member added or removed. That is the realistic
|
|
1089
|
+
repeat-use case, and considerably more valuable than a fresh full review each time.
|
|
1090
|
+
|
|
1091
|
+
Steps 1 and 2 are the existing CLI; only step 3 is new. That split is deliberate — the
|
|
1092
|
+
policy layer is a library and a skill you can replace, not something the runtime does.
|
|
1093
|
+
|
|
1094
|
+
### Gating a release on the audit, in CI
|
|
1095
|
+
|
|
1096
|
+
The review needs judgement, so nothing here pretends a script can perform it. What a script
|
|
1097
|
+
can do is refuse to proceed without one — and that needs the audit to produce something a
|
|
1098
|
+
build step can read. So the skill writes a JSON verdict beside its prose report:
|
|
1099
|
+
|
|
1100
|
+
```json
|
|
1101
|
+
{ "sha256": "f59a1a0a…", "baselineSha256": "e18542c9…", "mode": "sign",
|
|
1102
|
+
"verdict": "pass", "members": 904, "reviewed": 12, "findings": [],
|
|
1103
|
+
"summary": "12 members changed since 0.1.3; the sigstore tree is upstream and unmodified" }
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
`bundle audit --check` reads it, re-hashes the archive, and exits non-zero unless the
|
|
1107
|
+
verdict passed *and* names those exact bytes. That is the whole gate, and it works the same
|
|
1108
|
+
locally and in CI.
|
|
1109
|
+
|
|
1110
|
+
[`.github/workflows/publish.yml`](.github/workflows/publish.yml) is that pipeline as a
|
|
1111
|
+
workflow. On a push to `main` whose `package.json` version npm does not have yet, it runs:
|
|
1112
|
+
|
|
1113
|
+
```
|
|
1114
|
+
test → pack → fetch the published release → audit the diff → gate → sign → publish
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
Step 3 is [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action)
|
|
1118
|
+
in automation mode — a `prompt` input rather than an `@claude` mention — invoking this
|
|
1119
|
+
repository's own skill:
|
|
1120
|
+
|
|
1121
|
+
```yaml
|
|
1122
|
+
- name: Install the audit skill
|
|
1123
|
+
run: node dist/main.js skill # into .claude/skills/, where the action looks
|
|
1124
|
+
|
|
1125
|
+
- name: Audit — review the difference before signing
|
|
1126
|
+
uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1
|
|
1127
|
+
env:
|
|
1128
|
+
BUNDLE_AUDIT_VERDICT: build/cli.audit.json
|
|
1129
|
+
with:
|
|
1130
|
+
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
|
1131
|
+
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
|
1132
|
+
prompt: "/audit-bundle build/cli.bundle against build/baseline.bundle …"
|
|
1133
|
+
claude_args: |
|
|
1134
|
+
--max-turns 120
|
|
1135
|
+
--allowedTools "Bash,Read,Glob,Grep,Write"
|
|
1136
|
+
|
|
1137
|
+
- name: Gate on the audit verdict
|
|
1138
|
+
run: node dist/main.js audit --check --baseline build/baseline.bundle build/cli.bundle
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
**Why a diff.** Re-reading 904 unchanged members every release is the kind of review that
|
|
1142
|
+
decays into a rubber stamp; a small diff gets read properly. `tools/baseline.ts` fetches the
|
|
1143
|
+
currently published package with `npm pack` — which downloads without installing or running
|
|
1144
|
+
anything — pulls its `bundle.run` out, and **verifies it before using it**, optionally
|
|
1145
|
+
requiring the release workflow's own identity. A baseline that cannot be placed would make
|
|
1146
|
+
the diff lie by omission: everything it already contained would read as "unchanged" and
|
|
1147
|
+
therefore go unread. With no baseline at all — the first release — it says so and the audit
|
|
1148
|
+
reviews everything.
|
|
1149
|
+
|
|
1150
|
+
**Why the actions are pinned to commit SHAs.** A tag is a mutable pointer. Whoever can move
|
|
1151
|
+
`v1` can change what runs inside a job holding an OIDC token that can sign releases and
|
|
1152
|
+
publish to npm — which is precisely the attack this project exists to talk about, so the
|
|
1153
|
+
workflow that publishes it should not be open to it. Update the SHAs deliberately and read
|
|
1154
|
+
the diff when you do.
|
|
1155
|
+
|
|
1156
|
+
**Why signing and publishing use the same identity.** `permissions: id-token: write` gets
|
|
1157
|
+
one OIDC token; Fulcio certifies it for the signature, and `npm publish --provenance` files
|
|
1158
|
+
its attestation from it. Two attestations over one artifact, from one identity: npm's over
|
|
1159
|
+
the registry copy, and this project's over the bytes inside it.
|
|
1160
|
+
|
|
1161
|
+
> **It is disabled, not blocked.** Every step depends on `--vfs-load`, which shipped in
|
|
1162
|
+
> v26.10.0, so there is now a `node-version` it can run on; what it still needs is its
|
|
1163
|
+
> secrets, a committed lockfile for its `npm ci`, and a decision to publish. The
|
|
1164
|
+
> file is disabled two ways over — it does not end in `.yml`, so GitHub never parses it, and
|
|
1165
|
+
> its body is commented out — and it carries the one-line command that turns it back into a
|
|
1166
|
+
> live workflow. It is there to be read.
|
|
1167
|
+
|
|
1168
|
+
Two honest limits. An LLM review is a good reviewer, not a proof — it raises the cost of
|
|
1169
|
+
slipping something past and does not reduce it to zero, which is what the `severity` and
|
|
1170
|
+
`summary` fields are for: they leave a record of what was judged acceptable and why, so a
|
|
1171
|
+
later reader can disagree with it. And the gate protects the *publisher's* pipeline; a
|
|
1172
|
+
consumer who wants the same assurance runs the same skill over what they received, which is
|
|
1173
|
+
the point of it being one review at two points rather than a build-only step.
|
|
1174
|
+
|
|
1175
|
+
---
|
|
1176
|
+
|
|
1177
|
+
## Why these changes to Node make sense
|
|
1178
|
+
|
|
1179
|
+
The through-line is: **let a single file be the file tree a program runs from.**
|
|
1180
|
+
|
|
1181
|
+
- **Distribution wants one artifact, but applications are trees.** ZIP is the obvious
|
|
1182
|
+
container for a tree, and `baseOffset` is the one small primitive that lets a ZIP live
|
|
1183
|
+
*inside* another file — after a shebang, after a binary — without ceasing to be a valid
|
|
1184
|
+
ZIP. That is what makes "append and go" possible instead of "carve out a section and
|
|
1185
|
+
inject with a separate tool."
|
|
1186
|
+
- **Resolution has to believe the tree.** Bundlers fail at the edges because they rewrite
|
|
1187
|
+
*some* module loading but the rest of Node — legacy main resolution, `package.json`
|
|
1188
|
+
lookup, extension sniffing, and every user `fs.readFile` — still points at the real disk.
|
|
1189
|
+
Routing those primitives through a VFS-aware layer (that is a no-op when nothing is
|
|
1190
|
+
mounted) means the *whole* runtime, not just the bundler's slice, agrees on where files
|
|
1191
|
+
are. Assets and addons come along for free.
|
|
1192
|
+
- **The self-mounting shebang is the elegant payoff.** Because the kernel appends the
|
|
1193
|
+
invoked script's own path to a trailing `--vfs-load`, so the script mounts *itself*,
|
|
1194
|
+
a plain executable ZIP with a one-line header behaves
|
|
1195
|
+
like an installed program — no launcher, no wrapper, no unpacking. It's the Python
|
|
1196
|
+
zipapp / self-extracting-jar idea, but resolved natively by the runtime rather than
|
|
1197
|
+
bootstrapped by user code.
|
|
1198
|
+
- **The manifest closes the "what do I even ship?" problem.** Static dependency analysis is
|
|
1199
|
+
perennially wrong for dynamic `require`, data files, and conditional imports.
|
|
1200
|
+
A recording provider answers it empirically: *these are the files this run touched.*
|
|
1201
|
+
Combined with the mount, build-time discovery and run-time resolution use the same
|
|
1202
|
+
mechanism — and once selection is an extension point, discovery doesn't need to be a
|
|
1203
|
+
flag in the runtime at all.
|
|
1204
|
+
- **SEA is another delivery mode, not a different world.** By carrying `--build-sea`,
|
|
1205
|
+
ESM SEA entry points, and their code cache, the *same* archive that powers `app.run` also
|
|
1206
|
+
powers `app.sea`. You choose "small, needs Node" vs. "large, needs nothing" per target
|
|
1207
|
+
without changing how you package.
|
|
1208
|
+
- **A pluggable provider registry turns "can mount an archive" into "can refuse to."**
|
|
1209
|
+
Selection happening after `-r` preloads, and registered providers outranking the built-in
|
|
1210
|
+
one, are what let a policy — verify this signature, or don't mount at all — sit *below*
|
|
1211
|
+
the entry point instead of in it. An application cannot forget to call its own checker if
|
|
1212
|
+
the checker is what produced its filesystem.
|
|
1213
|
+
|
|
1214
|
+
### Honest limitations
|
|
1215
|
+
|
|
1216
|
+
- **VFS is not a sandbox.** It redirects `fs`; it does not confine untrusted code. Real
|
|
1217
|
+
isolation still needs OS-level mechanisms. Node's own docs say so.
|
|
1218
|
+
- **A verified mount is an integrity gate, not a confinement.** It proves the code is the
|
|
1219
|
+
code that was signed, by someone whose chain you trust. Once that code runs it has the
|
|
1220
|
+
full authority of the process — the guarantee is about *provenance*, not privilege.
|
|
1221
|
+
- **The gate is only as strong as how the runtime was launched.** Anyone who can change the
|
|
1222
|
+
command line can drop the `-r`, and the archive is mounted with the built-in provider,
|
|
1223
|
+
which verifies nothing. Registration is a userland opt-in, not a runtime policy.
|
|
1224
|
+
- **Native SEAs are large** because they include a full Node. That's inherent to
|
|
1225
|
+
zero-dependency native distribution, not a flaw in the approach.
|
|
1226
|
+
- **Signing proves provenance, and provenance is not safety.** Every significant npm
|
|
1227
|
+
compromise of recent years shipped a correctly signed package from a legitimately
|
|
1228
|
+
compromised account. A signature would have confirmed it came from the real maintainer
|
|
1229
|
+
and been useless. That is what the audit skill is for, and why it is a separate step.
|
|
1230
|
+
- **A sigstore signature is only as private as the transparency log.** Signing puts your
|
|
1231
|
+
identity, the archive's hash and the time in a public append-only log. That is the
|
|
1232
|
+
mechanism working — it is what makes the ten-minute certificate verifiable later — but it
|
|
1233
|
+
is not something to discover after the fact.
|
|
1234
|
+
- **Everything here is experimental** — a personal fork, `--experimental-vfs`, `REPLACEME`
|
|
1235
|
+
version markers. It's a proof of concept for a distribution model, not a supported
|
|
1236
|
+
product.
|
|
1237
|
+
|
|
1238
|
+
---
|
|
1239
|
+
|
|
1240
|
+
## Layout
|
|
1241
|
+
|
|
1242
|
+
```
|
|
1243
|
+
bundles/
|
|
1244
|
+
src/ the tool, in TypeScript; compiled to ESM in dist/
|
|
1245
|
+
index.ts the package root: create / sign / verify / inspect / run, from code
|
|
1246
|
+
api.ts the programmatic drive the CLI is a wrapper over
|
|
1247
|
+
cli.ts parseArgs and reporting; main(argv, io) -> exit code
|
|
1248
|
+
main.ts the executable entry, and the package `main` --vfs-load= runs
|
|
1249
|
+
archive.ts bundle() builds unsigned from a directory; rebundle() re-emits behind a
|
|
1250
|
+
new prefix and signs; keySigner() is the offline-CA signer
|
|
1251
|
+
manifest.ts buildManifest() / parseManifest() / verifySync(): the format and its check
|
|
1252
|
+
provider.ts BundleProvider + register(): verify at mount, hash every member at fetch
|
|
1253
|
+
register.ts the `-r` preload; configured via BUNDLE_ROOTS / BUNDLE_ALLOW_UNTRUSTED / …
|
|
1254
|
+
recorder.ts recording() + Manifest: the userland replacement for --vfs-manifest
|
|
1255
|
+
record.ts the `-r` preload for recording; configured via BUNDLE_MANIFEST
|
|
1256
|
+
launch.ts verify a container, mount it, run it — and the verifying node's CLI
|
|
1257
|
+
sea.ts createSeaBase() / buildSea(): building the executables
|
|
1258
|
+
sigstore.ts the sigstore signer (two-phase) and synchronous bundle verification
|
|
1259
|
+
oidc.ts identity tokens: ambient CI, browser sign-in, or device code
|
|
1260
|
+
files.ts dependency closures, for what an observation run cannot see
|
|
1261
|
+
skill.ts the skills this package ships, and installing them into a project
|
|
1262
|
+
preload.ts the shared plumbing the two `-r` preloads need
|
|
1263
|
+
types/ node:zlib's ZIP API and node:vfs's provider registry, until @types/node has them
|
|
1264
|
+
tools/ the build steps that are not just a CLI call:
|
|
1265
|
+
manifest.ts observe a run, close over the dependencies, write the list
|
|
1266
|
+
observe.ts the recording mount that run goes through
|
|
1267
|
+
baseline.ts fetch and verify the published release, to diff against
|
|
1268
|
+
audit.ts prepare the audit, and gate signing on its verdict
|
|
1269
|
+
prepublish.ts refuse to publish a stale or unsigned CLI
|
|
1270
|
+
.github/workflows/ci.yml build, typecheck and test on node 26.10.0
|
|
1271
|
+
.github/workflows/publish.yml the release pipeline: publishes any version npm lacks
|
|
1272
|
+
test/ 150 tests over the format, both providers, the API, the CLI, the SEA and the package
|
|
1273
|
+
skills/audit-bundle/
|
|
1274
|
+
the audit skill: verify -> extract -> security-review every file.
|
|
1275
|
+
`bundle skill` writes it into a project's .claude/skills/
|
|
1276
|
+
tools/testpki.ts generates a throwaway PKI into build/certs/ on demand
|
|
1277
|
+
shell-base shebang prefix for the portable archive
|
|
1278
|
+
tsconfig.json strict, ESM, erasable syntax only — so `node src/main.ts` runs the sources
|
|
1279
|
+
package.json the build / pack:cli / sign:cli / verify:cli / trust / test scripts
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
Build outputs — `dist/`, `build/cli.bundle` and the signed `bundle.run` — are generated
|
|
1283
|
+
by those scripts and are not in the repository.
|
|
1284
|
+
|
|
1285
|
+
**Environment variables**
|
|
1286
|
+
|
|
1287
|
+
| | |
|
|
1288
|
+
|---|---|
|
|
1289
|
+
| `BUNDLE_MANIFEST` | where the recording provider writes the observed file list |
|
|
1290
|
+
| `BUNDLE_ROOTS` | extra trusted roots for verification, path-delimiter separated |
|
|
1291
|
+
| `BUNDLE_ALLOW_UNTRUSTED` | mount an archive whose signature is good but untrusted |
|
|
1292
|
+
| `BUNDLE_IDENTITY` / `BUNDLE_ISSUER` | require a particular sigstore signer at mount time |
|
|
1293
|
+
| `BUNDLE_SIGSTORE_ROOT` | path to the sigstore trust root, instead of the cache |
|
|
1294
|
+
| `BUNDLE_NO_BROWSER` | never try to open a browser; use the device flow |
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
---
|
|
1298
|
+
|
|
1299
|
+
# Implementation notes
|
|
1300
|
+
|
|
1301
|
+
Design that was decided-enough to write down before it was built. These were kept in a
|
|
1302
|
+
separate file for most of the project's life, on the theory that the reasoning is worth
|
|
1303
|
+
more than the diff; they live here now because the reasoning and the argument are the same
|
|
1304
|
+
document read at two zoom levels. Where an implementation departed from its plan, that is
|
|
1305
|
+
recorded inline rather than edited away — a design note that only ever describes what
|
|
1306
|
+
happened is a changelog, and the interesting part is usually the gap.
|
|
1307
|
+
|
|
1308
|
+
| | |
|
|
1309
|
+
|---|---|
|
|
1310
|
+
| **§1 signing-time attestation** | Built. The marker grammar is extensible, and the evidence rides in a `SIGSTORE=` field carrying a full sigstore bundle — transparency-log entry *and* RFC 3161 token — rather than a bare TSA token. |
|
|
1311
|
+
| **§2 the audit skill** | Built, as `skills/audit-bundle/`. |
|
|
1312
|
+
| **§3 the tool as a bundle of itself** | Built. The published package carries its own CLI as one signed archive and the `bundle` command is a launcher for it; the sigstore dependencies became members, as this section said they would have to. |
|
|
1313
|
+
| **§4 the self-validating executable** | Built, as `src/sea.ts` and `bundle sea`. The VFS mount that drives a SEA, applied to the archive appended to it. |
|
|
1314
|
+
| **§5 the audit as a build step** | Built, as `bundle audit`, `tools/baseline.ts` and `.github/workflows/publish.yml`. The review moves from something you do to an archive you received to something that happens between `create` and `sign`. |
|
|
1315
|
+
|
|
1316
|
+
---
|
|
1317
|
+
|
|
1318
|
+
### 1. Signing-time attestation, so short-lived certificates verify later
|
|
1319
|
+
|
|
1320
|
+
> **Built.** What follows is the reasoning; see the note at the end of this section for
|
|
1321
|
+
> where the implementation differs.
|
|
1322
|
+
|
|
1323
|
+
#### The problem
|
|
1324
|
+
|
|
1325
|
+
`verifySync()` anchors the chain with `anchored(chain, roots, now)`, and `within()`
|
|
1326
|
+
requires **every certificate in the chain to be inside its validity window at `now`**,
|
|
1327
|
+
which defaults to `Date.now()` (`lib/manifest.js`). That is correct for a long-lived
|
|
1328
|
+
code-signing certificate and wrong for a short-lived one.
|
|
1329
|
+
|
|
1330
|
+
Sigstore's Fulcio issues certificates valid for about **ten minutes** — deliberately, so
|
|
1331
|
+
there is no long-lived key to steal. An archive signed that way verifies as `valid` for
|
|
1332
|
+
ten minutes and `valid-untrusted` forever after. The signature is still good; the
|
|
1333
|
+
verifier is simply asking the wrong question.
|
|
1334
|
+
|
|
1335
|
+
The right question is not *"is this certificate valid now?"* but *"was it valid when the
|
|
1336
|
+
signature was made?"* — which requires the archive to carry a trustworthy assertion of
|
|
1337
|
+
when that was.
|
|
1338
|
+
|
|
1339
|
+
#### The circular approach, and why it fails
|
|
1340
|
+
|
|
1341
|
+
The obvious move — put the Rekor transparency-log entry in the archive as a member — does
|
|
1342
|
+
not work:
|
|
1343
|
+
|
|
1344
|
+
1. The archive is hashed and signed, producing `H` and `S`.
|
|
1345
|
+
2. Submitting `(H, S, cert)` to Rekor yields a signed entry timestamp.
|
|
1346
|
+
3. Adding that as a member changes the member list, so the central directory changes, so
|
|
1347
|
+
`H` changes, so `S` is invalid.
|
|
1348
|
+
4. Re-signing produces a new `H`, so a new log entry, so back to 3.
|
|
1349
|
+
|
|
1350
|
+
Anything placed **inside the hashed region** has this problem. The timestamp is obtained
|
|
1351
|
+
*after* the signature exists, so it can never be part of what the signature covers.
|
|
1352
|
+
|
|
1353
|
+
#### The resolution: the EOCD comment is already an unsigned-attribute region
|
|
1354
|
+
|
|
1355
|
+
This is a solved problem in code signing generally. [RFC 3161][rfc3161] puts the timestamp
|
|
1356
|
+
token in the CMS `SignerInfo` **`unsignedAttrs`**, for exactly this reason: the token is
|
|
1357
|
+
produced after the signature, so it cannot live inside the signed data. Authenticode does
|
|
1358
|
+
the same with a counter-signature unsigned attribute.
|
|
1359
|
+
|
|
1360
|
+
This format already has such a region. The whole-file hash deliberately stops short of the
|
|
1361
|
+
EOCD comment, and `bundle()` writes that comment *after* hashing and signing:
|
|
1362
|
+
|
|
1363
|
+
```
|
|
1364
|
+
[ prefix | members | AUTHORITY.PEM | central directory | EOCD fixed part ] [ comment ]
|
|
1365
|
+
\___________________ hashed region → H _______________________________/ \__ not hashed
|
|
1366
|
+
SIGNED:H:S
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
So the timestamp goes in the comment, beside the signature — not in a member.
|
|
1370
|
+
|
|
1371
|
+
#### Proposed change
|
|
1372
|
+
|
|
1373
|
+
**Build** (`lib/archive.js`, step 3 of `bundle()` — already runs after hashing):
|
|
1374
|
+
|
|
1375
|
+
1. Compute `H`, sign it → `S`. Unchanged.
|
|
1376
|
+
2. Send **`S`** to a timestamp authority; receive a token.
|
|
1377
|
+
3. Write `SIGNED:<H>:<S>:TS=<base64 token>` as the EOCD comment.
|
|
1378
|
+
|
|
1379
|
+
No re-hash, no re-sign, no loop.
|
|
1380
|
+
|
|
1381
|
+
**Verify** (`lib/manifest.js`):
|
|
1382
|
+
|
|
1383
|
+
1. Recompute the hash of the region and match `H`. Unchanged — still the cheap,
|
|
1384
|
+
certificate-free first gate.
|
|
1385
|
+
2. Parse the token from the comment; verify it against a **pinned** TSA key/root. This
|
|
1386
|
+
yields a trusted time `T`, bound to `S`.
|
|
1387
|
+
3. Verify `S` over `H` against the leaf. Unchanged.
|
|
1388
|
+
4. Anchor the chain with `now = T` instead of `Date.now()`.
|
|
1389
|
+
|
|
1390
|
+
`verifySync()` already accepts a `now` option, so step 4 needs no new plumbing.
|
|
1391
|
+
|
|
1392
|
+
**Code touch points**
|
|
1393
|
+
|
|
1394
|
+
| Where | Change |
|
|
1395
|
+
|---|---|
|
|
1396
|
+
| `parseSignature()` | The regex is anchored at exactly two fields: `/^SIGNED:([0-9a-f]+):([0-9a-f]+)$/i`. Needs an extensible trailing-field grammar, and must stay backward compatible with two-field markers. |
|
|
1397
|
+
| `bundle()` | Request and append the token after signing. |
|
|
1398
|
+
| `inspect()` | Verify the token, derive `T`, pass it as `now`. |
|
|
1399
|
+
| trust config | A pinned TSA root, alongside the existing `extraRoots` / `BUNDLE_ROOTS`. |
|
|
1400
|
+
|
|
1401
|
+
#### Timestamp `S`, not `H`
|
|
1402
|
+
|
|
1403
|
+
A token over `H` proves *the archive* existed at `T`. A token over `S` proves *the
|
|
1404
|
+
signature* existed at `T`, which is the question actually being asked about certificate
|
|
1405
|
+
validity. CMS convention is to timestamp the signature value.
|
|
1406
|
+
|
|
1407
|
+
#### Why an unhashed comment is acceptable here
|
|
1408
|
+
|
|
1409
|
+
The comment is not covered by `H`, so it is malleable. That is not exploitable:
|
|
1410
|
+
|
|
1411
|
+
- **Strip the token** → verification falls back to `Date.now()` → the certificate reads as
|
|
1412
|
+
expired → result degrades to `valid-untrusted`. A denial of service, not a forgery.
|
|
1413
|
+
- **Forge a token** → requires the TSA's key.
|
|
1414
|
+
- **Transplant a token from another archive** → it binds to that archive's `S`.
|
|
1415
|
+
- **Obtain a fresh token** → a later `T` only makes the certificate look more expired.
|
|
1416
|
+
|
|
1417
|
+
This is also no weaker than the status quo: `S` itself already lives in the unhashed
|
|
1418
|
+
comment and self-authenticates. Nothing about the trust model changes.
|
|
1419
|
+
|
|
1420
|
+
**Size budget.** The EOCD comment length field is 2 bytes → 65535 bytes. An RFC 3161 token
|
|
1421
|
+
carrying its TSA chain is roughly 2–4 KB. Not a constraint.
|
|
1422
|
+
|
|
1423
|
+
#### Cheaper alternatives, if this is too much machinery
|
|
1424
|
+
|
|
1425
|
+
- **Self-asserted signing time.** A `!signed <RFC3339>` directive in `AUTHORITY.PEM` sits
|
|
1426
|
+
*inside* the hashed region, so it is covered by `S` and is tamper-evident. But it is only
|
|
1427
|
+
as good as the signer's clock, and it gives back the bounded-damage property that
|
|
1428
|
+
short-lived certificates exist to provide. A convenience fallback, not an equivalent —
|
|
1429
|
+
and it should produce a distinguishable result state, not silently pass as `valid`.
|
|
1430
|
+
- **Verify once at ingest, then pin the hash.** Sidesteps long-lived re-verification
|
|
1431
|
+
entirely. For many deployment models this is the right answer and needs no format change
|
|
1432
|
+
at all.
|
|
1433
|
+
|
|
1434
|
+
#### Open questions
|
|
1435
|
+
|
|
1436
|
+
- Which TSA to pin by default. Sigstore operates one; confirm the current endpoint and
|
|
1437
|
+
root distribution rather than hard-coding a hostname.
|
|
1438
|
+
- Whether an archive with a good signature but *no* timestamp and an expired certificate
|
|
1439
|
+
deserves its own state/reason rather than collapsing into `valid-untrusted`.
|
|
1440
|
+
- Whether to support Rekor inclusion proofs as an alternative to a plain TSA token. Same
|
|
1441
|
+
placement, strictly more verification work; only worth it if log transparency is wanted
|
|
1442
|
+
for its own sake.
|
|
1443
|
+
|
|
1444
|
+
#### What was actually built
|
|
1445
|
+
|
|
1446
|
+
The placement argument survived intact — the evidence lives in the EOCD comment, beside
|
|
1447
|
+
the signature, because it postdates it. Three things landed differently:
|
|
1448
|
+
|
|
1449
|
+
- **A sigstore bundle, not a bare TSA token.** The `SIGSTORE=` field carries the whole
|
|
1450
|
+
sigstore bundle: the Fulcio certificate, the signature, the Rekor entry *and* any RFC
|
|
1451
|
+
3161 timestamps. That answers the "Rekor inclusion proofs as an alternative?" open
|
|
1452
|
+
question with *both*, because the bundle format carries both and `@sigstore/verify`
|
|
1453
|
+
checks whichever are present.
|
|
1454
|
+
- **The signing time is not plumbed through `now`.** The plan was to derive `T` and pass
|
|
1455
|
+
it to the existing `anchored()`. In practice the sigstore path *replaces* X.509
|
|
1456
|
+
anchoring rather than parameterising it: `Verifier` does certificate-to-Fulcio-root,
|
|
1457
|
+
SCT, log inclusion, timestamps and the signature as one coherent check, and splitting
|
|
1458
|
+
that across two trust models would have been strictly worse. `now` is simply ignored
|
|
1459
|
+
when a `SIGSTORE=` field is present.
|
|
1460
|
+
- **No TSA to pin.** The open question about which TSA to pin by default dissolved: the
|
|
1461
|
+
trust material comes from the sigstore trust root over TUF, refreshed by `bundle
|
|
1462
|
+
trust`, so the TSA and log keys are distributed the same way as the Fulcio roots and
|
|
1463
|
+
need no hard-coded hostname.
|
|
1464
|
+
|
|
1465
|
+
Two checks the design did not anticipate turned out to be necessary, both because the
|
|
1466
|
+
field sits in the malleable region. The bundle must be over *this* archive's hash, and
|
|
1467
|
+
the certificate it verifies against must be the one `AUTHORITY.PEM` names — otherwise a
|
|
1468
|
+
genuine bundle for another identity could be pinned to an archive whose extractable
|
|
1469
|
+
manifest claims a different signer. The signature would check out and the inspectable
|
|
1470
|
+
file would be a lie.
|
|
1471
|
+
|
|
1472
|
+
The remaining open question stands: an archive with a good signature, no timestamp and an
|
|
1473
|
+
expired certificate still collapses into `valid-untrusted` rather than getting a state of
|
|
1474
|
+
its own.
|
|
1475
|
+
|
|
1476
|
+
[rfc3161]: https://www.rfc-editor.org/rfc/rfc3161.html
|
|
1477
|
+
|
|
1478
|
+
---
|
|
1479
|
+
|
|
1480
|
+
### 2. An audit skill: verify → extract → review
|
|
1481
|
+
|
|
1482
|
+
> **Built**, as `skills/audit-bundle/SKILL.md`, following this shape closely —
|
|
1483
|
+
> including the diff mode and the non-skippable verification step.
|
|
1484
|
+
|
|
1485
|
+
#### Why a bundle is the right shape for this
|
|
1486
|
+
|
|
1487
|
+
A signed archive answers *who produced these bytes*. It does not answer *are these bytes
|
|
1488
|
+
safe*. Those are different questions, and every npm compromise of recent years shipped a
|
|
1489
|
+
correctly published, correctly signed package from a legitimately compromised account —
|
|
1490
|
+
provenance alone would have confirmed it came from the real maintainer and been useless.
|
|
1491
|
+
|
|
1492
|
+
What makes the second question tractable here is that a bundle is a **closed set**: nothing
|
|
1493
|
+
resolves later, nothing is fetched at install, no lifecycle script pulls in more code. A
|
|
1494
|
+
review over it can be complete in a way that a review of a dependency tree cannot.
|
|
1495
|
+
|
|
1496
|
+
#### Shape
|
|
1497
|
+
|
|
1498
|
+
A Claude Code skill that takes an archive path and:
|
|
1499
|
+
|
|
1500
|
+
1. **Verify** — run the existing verification. Report the state and the signing identity.
|
|
1501
|
+
Refuse to continue on `invalid`; continue with a clear warning on `unsigned` or
|
|
1502
|
+
`valid-untrusted`, since reviewing an untrusted archive is precisely the useful case.
|
|
1503
|
+
2. **Extract** — unzip to a scratch directory. It is a real zip, so this needs no special
|
|
1504
|
+
tooling. Record the identity from `AUTHORITY.PEM` alongside the extraction.
|
|
1505
|
+
3. **Review** — read every extracted file and report on:
|
|
1506
|
+
- install/lifecycle hooks and anything that runs at load time
|
|
1507
|
+
- obfuscated, minified, or encoded payloads in a source bundle
|
|
1508
|
+
- outbound network calls, especially in modules with no reason to make them
|
|
1509
|
+
- environment and credential reads; CI token and cloud metadata endpoints
|
|
1510
|
+
- `child_process`, `eval`, dynamic `require`, and other indirection
|
|
1511
|
+
- files present in the archive that nothing references
|
|
1512
|
+
|
|
1513
|
+
Output is a per-file report over a fixed, finite set of bytes.
|
|
1514
|
+
|
|
1515
|
+
#### Notes
|
|
1516
|
+
|
|
1517
|
+
- Steps 1 and 2 are the existing CLI; only step 3 is new work.
|
|
1518
|
+
- The verification step must not be skippable — an audit that silently reviewed a
|
|
1519
|
+
tampered archive would be worse than no audit.
|
|
1520
|
+
- Worth pairing with a diff mode: review only what changed against a previously approved
|
|
1521
|
+
archive, which is the realistic repeat-use case.
|
|
1522
|
+
|
|
1523
|
+
---
|
|
1524
|
+
|
|
1525
|
+
### 3. Shipping the tool as a bundle of itself
|
|
1526
|
+
|
|
1527
|
+
> **Built.** What follows is the reasoning; see the note at the end of this section for
|
|
1528
|
+
> where the implementation differs.
|
|
1529
|
+
|
|
1530
|
+
#### The claim
|
|
1531
|
+
|
|
1532
|
+
`bundle` should not be installed from npm. It should be distributed the way it tells
|
|
1533
|
+
everyone else to distribute: **one signed file**, verifiable by a copy of `bundle` you
|
|
1534
|
+
already have, and auditable as a closed set before you run it.
|
|
1535
|
+
|
|
1536
|
+
That makes the tool its own best demonstration. Every property this project claims — a file
|
|
1537
|
+
list discovered by observation, a whole-file signature, a mount that refuses what does not
|
|
1538
|
+
verify, a closed set an audit can be complete over — is exercised by the way the tool
|
|
1539
|
+
itself arrives on your machine. If the model does not hold up for `bundle`, it does not
|
|
1540
|
+
hold up for anything.
|
|
1541
|
+
|
|
1542
|
+
#### Why this is the answer to "where is the lock file?"
|
|
1543
|
+
|
|
1544
|
+
A lock file pins the bytes of a dependency tree that is still **fetched and executed at
|
|
1545
|
+
install time**. It is a real improvement over not having one, but note what it is
|
|
1546
|
+
improving: an install step that resolves a graph, runs lifecycle scripts, and ends with
|
|
1547
|
+
code on your disk that nobody looked at. Reproducing that exactly is worth something, and
|
|
1548
|
+
it is not the same as not needing it.
|
|
1549
|
+
|
|
1550
|
+
A signed bundle removes the step rather than pinning it:
|
|
1551
|
+
|
|
1552
|
+
| | lock file | signed bundle |
|
|
1553
|
+
|---|---|---|
|
|
1554
|
+
| what you fetch | a graph, resolved at install | one file |
|
|
1555
|
+
| what runs at install | lifecycle scripts, transitively | nothing; there is no install |
|
|
1556
|
+
| what you can review | in principle the tree, in practice not | every byte, and the set is finite |
|
|
1557
|
+
| what proves origin | the registry's word | a signature over the whole artifact |
|
|
1558
|
+
| what "the same bytes" means | same versions, re-resolved | literally the same bytes |
|
|
1559
|
+
|
|
1560
|
+
So the lock file is eye-candy *for this repo specifically* — the shipped artifact is not
|
|
1561
|
+
produced by `npm install` on the user's machine. It stays useful for contributors
|
|
1562
|
+
reproducing a build, which is a different question and an orthogonal one.
|
|
1563
|
+
|
|
1564
|
+
#### The chain of custody
|
|
1565
|
+
|
|
1566
|
+
Version *N* is verified by version *N−1*. That is an ordinary trust chain over time, and
|
|
1567
|
+
it is how package managers already handle their own updates:
|
|
1568
|
+
|
|
1569
|
+
```
|
|
1570
|
+
bundle@0.1 (you have it) --verify--> bundle@0.2.bundle --verify--> bundle@0.3.bundle
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
Each release is signed through sigstore by the publish workflow, so the identity to pin is
|
|
1574
|
+
a workflow ref rather than a person — except 0.0.1, published by hand before the workflow
|
|
1575
|
+
existed and signed as its maintainer:
|
|
1576
|
+
|
|
1577
|
+
```sh
|
|
1578
|
+
bundle verify --identity 'https://github.com/pipobscure/bundles/.github/workflows/publish.yml@refs/heads/main' \
|
|
1579
|
+
--issuer 'https://token.actions.githubusercontent.com' \
|
|
1580
|
+
bundle.run
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
`--identity` and `--issuer` already exist and already report a mismatch as
|
|
1584
|
+
`valid-untrusted` rather than `invalid` — genuinely signed, just not by who you demanded.
|
|
1585
|
+
`BUNDLE_IDENTITY`/`BUNDLE_ISSUER` impose the same policy at mount time, so a machine can
|
|
1586
|
+
be configured to run *only* releases from that workflow.
|
|
1587
|
+
|
|
1588
|
+
**Bootstrapping.** The first copy has to be trusted some other way; there is no way around
|
|
1589
|
+
that and pretending otherwise would be the dishonest part. The options are the usual ones —
|
|
1590
|
+
verify the sigstore identity by hand against the transparency log, or take it from a
|
|
1591
|
+
release page over TLS and audit it before first run. Trust-on-first-use, with the
|
|
1592
|
+
mitigation that TOFU here is over an artifact you can read completely.
|
|
1593
|
+
|
|
1594
|
+
#### The wrinkle: the tool has dependencies now
|
|
1595
|
+
|
|
1596
|
+
This is the part that needs work rather than just a decision. `lib/sigstore.js` pulls in
|
|
1597
|
+
`@sigstore/*`, and a bundle has no `node_modules` at runtime — so those libraries have to
|
|
1598
|
+
become **members of the bundle**.
|
|
1599
|
+
|
|
1600
|
+
That is not a workaround, it is the point: the dependency tree stops being a thing you
|
|
1601
|
+
fetch and becomes a fixed set of files inside one signed artifact that `/audit-bundle` can
|
|
1602
|
+
review in full. But it means:
|
|
1603
|
+
|
|
1604
|
+
- The manifest run has to resolve `node_modules`, not just `lib/`. Today `npm run manifest`
|
|
1605
|
+
mounts `lib/` alone, so nothing under `node_modules` is observed. The recording provider
|
|
1606
|
+
will capture them once the mount covers them — the mechanism is right, the mount point is
|
|
1607
|
+
not.
|
|
1608
|
+
- The result is a much larger member list, and it is the honest one. Roughly 96 packages
|
|
1609
|
+
came in with the sigstore libraries; every one of them would be visible in the manifest,
|
|
1610
|
+
which is uncomfortable and correct.
|
|
1611
|
+
- Verification-only builds could drop the signing half. `@sigstore/sign` and the OIDC flow
|
|
1612
|
+
are needed to *produce* a signature, not to check one, so a verify-only distribution is
|
|
1613
|
+
meaningfully smaller. Worth doing only if the size difference turns out to matter.
|
|
1614
|
+
|
|
1615
|
+
Until then the shipped `app.run` carries no sigstore libraries, which is why it degrades a
|
|
1616
|
+
sigstore-signed archive to `valid-untrusted` — correct behaviour, and a limitation this
|
|
1617
|
+
section is what fixes.
|
|
1618
|
+
|
|
1619
|
+
#### Open questions
|
|
1620
|
+
|
|
1621
|
+
- **Downgrade.** A correctly signed old release stays valid forever, which is the point of
|
|
1622
|
+
the timestamp and also means nothing stops someone handing you version 0.1 tomorrow.
|
|
1623
|
+
Whether the tool should carry a version floor, or leave that to whoever is deploying it,
|
|
1624
|
+
is undecided. Probably the latter — it is a policy, and policy is the thing this project
|
|
1625
|
+
keeps insisting does not belong in the mechanism.
|
|
1626
|
+
- **What the release artifact actually is.** A plain `.bundle` needs Node plus the preload
|
|
1627
|
+
flags; an `app.run` shebang runs anywhere with a compatible Node; a SEA needs nothing and
|
|
1628
|
+
costs 150 MB. Probably all three, but only the shebang one is a pleasant default.
|
|
1629
|
+
- **Whether `npm` publication continues in parallel.** Useful for people embedding the
|
|
1630
|
+
library, and it should be explicit that the npm package is the *library* and the signed
|
|
1631
|
+
bundle is the *tool*, rather than pretending the registry copy does not exist.
|
|
1632
|
+
|
|
1633
|
+
#### What was actually built
|
|
1634
|
+
|
|
1635
|
+
The chain-of-custody argument survived intact. Three things landed differently, all of them
|
|
1636
|
+
about the *npm* copy rather than about the model.
|
|
1637
|
+
|
|
1638
|
+
- **npm publication continues, and carries the signed bundle inside it.** The open question
|
|
1639
|
+
resolved into "both, in one package": the registry copy is the library (an `exports` map
|
|
1640
|
+
of ESM entry points, typed), and beside it sits `bundle.run` — the CLI as one signed
|
|
1641
|
+
archive, 904 members including the whole sigstore dependency tree. `bin` points *straight
|
|
1642
|
+
at it*: it carries a `#!/bin/sh` prefix that mounts and runs itself, so
|
|
1643
|
+
`npx @pipobscure/bundle` executes the signed artifact with nothing in between. That keeps
|
|
1644
|
+
the model honest without pretending the registry does not exist.
|
|
1645
|
+
|
|
1646
|
+
A first attempt put a small JavaScript launcher in front, which verified the archive
|
|
1647
|
+
before mounting it. Removing it was the right call and worth recording why: the launcher
|
|
1648
|
+
was unsigned code installed beside the thing it vouched for, so anybody able to swap the
|
|
1649
|
+
archive could equally swap the launcher — it detected corruption, not an adversary — and
|
|
1650
|
+
it put an opaque step in front of the one file a reader should be looking at. Inspectability
|
|
1651
|
+
was the thing being traded away, and inspectability is the argument.
|
|
1652
|
+
- **The member list is computed, not observed.** The section assumed the manifest run would
|
|
1653
|
+
simply cover `node_modules` once the mount did. It does not, and the reason is the thing
|
|
1654
|
+
worth recording: `sigstore.ts` requires `@sigstore/verify` only when it meets an archive
|
|
1655
|
+
carrying a `SIGSTORE=` field, so an observation run that signs with a local key never
|
|
1656
|
+
loads it — and the resulting bundle could not check a sigstore signature it later met.
|
|
1657
|
+
Observation cannot see a path it did not take. So `src/files.ts` computes the dependency
|
|
1658
|
+
closure through `node_modules`, and the observation run is kept as the *check* on it:
|
|
1659
|
+
anything read that the closure missed stops the build. Both mechanisms, each doing what it
|
|
1660
|
+
is good at.
|
|
1661
|
+
- **Unanchored is not a refusal, for the launcher specifically.** A sigstore-signed release
|
|
1662
|
+
is `valid-untrusted` on a machine that has never run `bundle trust`, and refusing there
|
|
1663
|
+
would make `npx` fail out of the box for a correct artifact. The launcher warns and
|
|
1664
|
+
continues on `valid-untrusted`, refuses outright on `invalid` or `unsigned`, and takes
|
|
1665
|
+
`BUNDLE_STRICT=1` to make the middle case fatal. (`trustedRootSync()` also falls back to
|
|
1666
|
+
the trust root `@sigstore/tuf` ships as a seed, so the common case needs no network.)
|
|
1667
|
+
|
|
1668
|
+
One thing the section did not anticipate: the identity policy had a hole. `--identity` and
|
|
1669
|
+
`BUNDLE_IDENTITY` were only consulted on the sigstore path, so an archive signed against an
|
|
1670
|
+
ordinary CA — which carries no identity claim at all — passed a policy demanding one. A
|
|
1671
|
+
machine configured to run *only* releases from a workflow would have mounted anything
|
|
1672
|
+
key-signed. It now reports `valid-untrusted`: the signature is genuine and simply is not the
|
|
1673
|
+
one demanded.
|
|
1674
|
+
|
|
1675
|
+
The downgrade question is still open and still, deliberately, unanswered: a correctly signed
|
|
1676
|
+
old release stays valid forever, and whether to carry a version floor is a policy, which is
|
|
1677
|
+
the thing this project keeps insisting does not belong in the mechanism.
|
|
1678
|
+
|
|
1679
|
+
---
|
|
1680
|
+
|
|
1681
|
+
### 4. The self-validating single executable
|
|
1682
|
+
|
|
1683
|
+
> **Built**, as `src/sea.ts` and `bundle sea`.
|
|
1684
|
+
|
|
1685
|
+
#### The claim
|
|
1686
|
+
|
|
1687
|
+
Every shape this tool produces gates on something outside itself. A `.bundle` needs the
|
|
1688
|
+
verifying provider preloaded; a shebang launcher has no preload at all and hands straight
|
|
1689
|
+
off to the application. The SEA is the shape that can carry its own gate, because the
|
|
1690
|
+
container *is* the runtime — and the interesting property is that the gate can be inside
|
|
1691
|
+
what it gates.
|
|
1692
|
+
|
|
1693
|
+
#### The shape
|
|
1694
|
+
|
|
1695
|
+
```
|
|
1696
|
+
[ node runtime | SEA blob: stub + verifier.bundle ] [ app.bundle ]
|
|
1697
|
+
\____________________ the prefix, and part of ___/
|
|
1698
|
+
\___________ the archive's signed region ______/
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
The application is an ordinary signed archive appended to a node binary — the same
|
|
1702
|
+
`sign --prefix` that produces a shebang launcher, pointed at a different prefix. The
|
|
1703
|
+
whole-file hash covers the prefix, so the runtime and the verifier inside it are signed by
|
|
1704
|
+
the same signature that covers the application. There is nothing to check the checker
|
|
1705
|
+
against because the checker is inside what is checked.
|
|
1706
|
+
|
|
1707
|
+
#### Why the verifier is a mount rather than an inlined copy
|
|
1708
|
+
|
|
1709
|
+
The bootstrap runs before anything is mounted, so it cannot import the library the ordinary
|
|
1710
|
+
way. The previous `sea.js` solved that by copying `manifest.js` into itself — about 180 lines
|
|
1711
|
+
of duplicated verification logic, which promptly drifted: its marker regex was still the
|
|
1712
|
+
two-field form, so it read every sigstore-signed container as *unsigned*.
|
|
1713
|
+
|
|
1714
|
+
The fix was to make the verifier reachable before the application is: this package's own
|
|
1715
|
+
files rode in the SEA blob as one `.bundle` asset, and a fifteen-line CommonJS stub mounted
|
|
1716
|
+
*that* with `node:vfs` and required the real library out of it. Two mounts, in order: the
|
|
1717
|
+
verifier's from the blob, then the application's from the archive at the end of the file.
|
|
1718
|
+
Nothing was duplicated, and the verifier the container ran was the one the test suite tests.
|
|
1719
|
+
|
|
1720
|
+
That was the userland form of [nodejs/node#65675](https://github.com/nodejs/node/pull/65675)
|
|
1721
|
+
(`"useVfs": true`), which puts a SEA's own assets behind a VFS mount and runs the main script
|
|
1722
|
+
from its root — and the note closed by predicting that when `useVfs` landed, the generated
|
|
1723
|
+
stub would be the only piece that changed.
|
|
1724
|
+
|
|
1725
|
+
**Which is what happened.** `useVfs`, with a `vfsArchive` naming the bundle, embeds the ZIP
|
|
1726
|
+
in the executable and mounts it as the file system the main script runs from. So the first
|
|
1727
|
+
of the two mounts is node's now, the stub is injected at the root of it, and reaching this
|
|
1728
|
+
package is one relative `import`. What is left of the stub is a handful of lines that pick
|
|
1729
|
+
between verifying the archive appended to the executable and verifying the one named on its
|
|
1730
|
+
command line — and it is an ES module, because it is injected into a bundle whose
|
|
1731
|
+
`package.json` says `"type": "module"` and format detection inside a mount follows the same
|
|
1732
|
+
rules it follows on disk.
|
|
1733
|
+
|
|
1734
|
+
#### What runs before the check
|
|
1735
|
+
|
|
1736
|
+
The stub and the verifier execute before the signature has been verified, and that is worth
|
|
1737
|
+
being explicit about rather than glossing. It is where the trust has to start: both live
|
|
1738
|
+
inside the prefix, which is inside the hashed region, so tampering with either invalidates
|
|
1739
|
+
the signature over the application — and an attacker who could rewrite the executable's own
|
|
1740
|
+
runtime could equally rewrite a verifier that ran first. The application never runs until
|
|
1741
|
+
the check passes.
|
|
1742
|
+
|
|
1743
|
+
The gain over the old arrangement is real, though: because the mount is the *verifying*
|
|
1744
|
+
provider rather than a plain `ZipProvider`, every member is re-hashed against its signed
|
|
1745
|
+
digest as it is first read, for the life of the process — the per-member guarantee the old
|
|
1746
|
+
`sea.js` explicitly could not offer.
|
|
1747
|
+
|
|
1748
|
+
#### Configuration, and where policy lives
|
|
1749
|
+
|
|
1750
|
+
An executable run by its own name has no flags and no preload, so `createSeaBase()` bakes
|
|
1751
|
+
the bootstrap options into the generated stub — trusted roots, a required sigstore identity
|
|
1752
|
+
and issuer, whether an unanchored chain is acceptable. Leave them off and the same
|
|
1753
|
+
`BUNDLE_ROOTS` / `BUNDLE_IDENTITY` / `BUNDLE_ALLOW_UNTRUSTED` variables the mount honours
|
|
1754
|
+
apply, so one build can be decided about later. Both are legitimate; which one you want is
|
|
1755
|
+
whether the policy belongs to the publisher or to the deployment.
|
|
1756
|
+
|
|
1757
|
+
#### Open questions
|
|
1758
|
+
|
|
1759
|
+
- **Cross-compilation.** `createSeaBase({ node })` takes the binary to embed, so building a
|
|
1760
|
+
container for another platform is a matter of having that platform's node to hand. Whether
|
|
1761
|
+
the tool should fetch one is a packaging decision it has so far declined to make.
|
|
1762
|
+
- **Size, again.** The verifier asset is about a megabyte inside a 155 MB runtime, so the
|
|
1763
|
+
sigstore libraries are not what makes a SEA large. A verify-only verifier would save
|
|
1764
|
+
little and cost a build variant; `sigstore: false` exists for anyone who disagrees.
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
---
|
|
1769
|
+
|
|
1770
|
+
### 5. The audit as a build step, and a gate that can act on it
|
|
1771
|
+
|
|
1772
|
+
> **Built**, as `tools/audit.ts`, the verdict contract in `skills/audit-bundle/SKILL.md`,
|
|
1773
|
+
> and `.github/workflows/publish.yml`.
|
|
1774
|
+
|
|
1775
|
+
#### The claim
|
|
1776
|
+
|
|
1777
|
+
§2 built the audit as something you do to an archive somebody sent you. That is half of it.
|
|
1778
|
+
The other half is that a signature is a claim about bytes you stand behind, so the review
|
|
1779
|
+
belongs *before* the signature, not only after it:
|
|
1780
|
+
|
|
1781
|
+
```
|
|
1782
|
+
observe → create → AUDIT → sign → … ship … → AUDIT → run
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
Both ends are the same review. Treating them as different tasks would be the mistake: if
|
|
1786
|
+
you would not run someone else's bundle without reading it, you should not sign your own
|
|
1787
|
+
without reading it either, and the standard is the one that survives being applied to
|
|
1788
|
+
yourself.
|
|
1789
|
+
|
|
1790
|
+
#### Why the ordering has to be create-then-audit, not audit-then-create
|
|
1791
|
+
|
|
1792
|
+
The tempting alternative is to review the source tree and then bundle it. That reviews the
|
|
1793
|
+
wrong thing. What ships is the archive, and the archive's member list came from an
|
|
1794
|
+
observation run — so the interesting question is not "is this source good" but "is this
|
|
1795
|
+
*set* the right set, and does everything in it belong". A dependency that arrived through
|
|
1796
|
+
the closure, a file the observation pulled in that nobody expected, a member nothing
|
|
1797
|
+
references: none of those are visible until the bundle exists. So the archive is built
|
|
1798
|
+
first, unsigned, and the review is over the thing that will actually be signed.
|
|
1799
|
+
|
|
1800
|
+
This also makes the failure cheap. An unsigned archive costs nothing to throw away, which
|
|
1801
|
+
is what makes this the right place to catch things — a finding here is "rebuild it", not
|
|
1802
|
+
"decide whether to accept a risk in something already published".
|
|
1803
|
+
|
|
1804
|
+
#### The gate
|
|
1805
|
+
|
|
1806
|
+
An audit needs judgement, so no script performs it. What a script can do is refuse to let
|
|
1807
|
+
step 4 happen without one, and that requires the audit to leave behind something a build
|
|
1808
|
+
step can read. Hence the verdict file: `verdict: "pass" | "fail"`, the findings with
|
|
1809
|
+
severities, and — the load-bearing field — the **sha256 of the archive**.
|
|
1810
|
+
|
|
1811
|
+
`bundle audit --check` re-hashes the file and refuses a verdict that names different
|
|
1812
|
+
bytes. Without that pin the gate is theatre: it would approve any later build on the
|
|
1813
|
+
strength of one earlier approval, which is precisely the failure mode of every
|
|
1814
|
+
"security review completed" checkbox. Rebuilding invalidates the approval, and it should.
|
|
1815
|
+
|
|
1816
|
+
`--approve` exists so a person who read the archive themselves is a first-class auditor; it
|
|
1817
|
+
writes the same file with `by: "human"`, so the gate treats both identically while the
|
|
1818
|
+
record still says which happened.
|
|
1819
|
+
|
|
1820
|
+
It is a *shipped command* rather than build tooling, and that was a correction. The gate
|
|
1821
|
+
lived in `tools/` at first, which meant the four-step flow this project preaches was
|
|
1822
|
+
available only to this project — the deck had a slide showing `npm run sign:cli` refusing,
|
|
1823
|
+
which no reader could reproduce. A pipeline you recommend and cannot hand over is an
|
|
1824
|
+
argument, not a tool.
|
|
1825
|
+
|
|
1826
|
+
#### Reviewing the diff, not the archive
|
|
1827
|
+
|
|
1828
|
+
Reviewing 904 members from scratch every release is expensive and, worse, is the same
|
|
1829
|
+
reading over the same unchanged dependency tree — the kind of review that decays into a
|
|
1830
|
+
rubber stamp precisely because nothing ever changes in most of it. What deserves attention
|
|
1831
|
+
is the difference: which members appeared, which vanished, and what changed inside the ones
|
|
1832
|
+
that stayed.
|
|
1833
|
+
|
|
1834
|
+
So the baseline is the **currently published release**, fetched with `npm pack` (which
|
|
1835
|
+
downloads without installing or running anything, which is the property the whole project
|
|
1836
|
+
is about) and verified before it is used. That verification is not ceremony. A diff makes
|
|
1837
|
+
everything the baseline already contained read as "unchanged", and therefore go unread — so
|
|
1838
|
+
a baseline that cannot be placed makes the review lie by omission rather than merely being
|
|
1839
|
+
less useful. `tools/baseline.ts` refuses an `unsigned` or `invalid` baseline outright, and
|
|
1840
|
+
can be told to require the release workflow's own signing identity.
|
|
1841
|
+
|
|
1842
|
+
This is the §3 chain of custody used for a second purpose. There, version *N* verifies
|
|
1843
|
+
version *N+1* to establish trust; here *N* is what *N+1* is read *against*. Same edge,
|
|
1844
|
+
different question.
|
|
1845
|
+
|
|
1846
|
+
Two states the design has to admit rather than paper over. The first release has no
|
|
1847
|
+
baseline: `--allow-missing` says so and the audit reviews everything. And a diff inherits
|
|
1848
|
+
every earlier verdict — an unchanged member is exactly as trustworthy as the review that
|
|
1849
|
+
cleared it last time, which is worth stating in the report rather than leaving implied.
|
|
1850
|
+
|
|
1851
|
+
#### Running the review in CI
|
|
1852
|
+
|
|
1853
|
+
`anthropics/claude-code-action@v1` runs Claude Code in *automation mode* when the workflow
|
|
1854
|
+
supplies a `prompt` rather than waiting for an `@claude` mention, and a prompt can be a
|
|
1855
|
+
skill invocation. So the release workflow installs the skill with the tool's own
|
|
1856
|
+
`bundle skill` command — into `.claude/skills/`, where the action looks — and invokes
|
|
1857
|
+
`/audit-bundle` over the freshly packed archive.
|
|
1858
|
+
|
|
1859
|
+
The action reports into the workflow log; it does not hand the job a structured result. That
|
|
1860
|
+
is why the gate is a separate step reading a file rather than a condition on the action's
|
|
1861
|
+
output, and why the verdict contract lives in the skill rather than in the workflow. The
|
|
1862
|
+
verdict is uploaded as a run artifact `if: always()`, so a refused release leaves its
|
|
1863
|
+
reasoning behind rather than only a red X.
|
|
1864
|
+
|
|
1865
|
+
Step 4 then signs through sigstore with the workflow's ambient OIDC identity, so the
|
|
1866
|
+
release is signed *as the workflow* — which is the identity §3 says a consumer should pin,
|
|
1867
|
+
and it closes the loop: the thing that was audited, the thing that was signed, and the
|
|
1868
|
+
thing whose identity you can check are all the same bytes. `npm publish --provenance` files
|
|
1869
|
+
its attestation from the same token, so the registry copy and the signed artifact inside it
|
|
1870
|
+
trace to one identity rather than two.
|
|
1871
|
+
|
|
1872
|
+
#### Pinning the actions
|
|
1873
|
+
|
|
1874
|
+
Every action is pinned to a full commit SHA rather than a tag. A tag is a mutable pointer,
|
|
1875
|
+
and whoever can move `v1` can change what runs inside a job that holds an OIDC token able
|
|
1876
|
+
to sign releases and publish to npm. That is not a hypothetical class of attack here; it is
|
|
1877
|
+
the same class HISTORY.md's opening argument is about, and a workflow that publishes *this*
|
|
1878
|
+
project while being open to it would be self-refuting.
|
|
1879
|
+
|
|
1880
|
+
The cost is real and worth naming: pinned SHAs do not pick up security fixes on their own,
|
|
1881
|
+
so they have to be updated deliberately, with the diff read. That is the trade, and it is
|
|
1882
|
+
the right one for a release pipeline specifically — less obviously so for ordinary CI.
|
|
1883
|
+
|
|
1884
|
+
#### How it is switched on
|
|
1885
|
+
|
|
1886
|
+
For a long time it could not be: until v26.10.0 there was no `node-version` GitHub Actions
|
|
1887
|
+
could install that carried `--vfs-load`, so the workflow sat in the repository as
|
|
1888
|
+
`release.yml.disabled`, commented out, to be read rather than run. Once 26.10 shipped it
|
|
1889
|
+
became `publish.yml`, with two changes that make it cheap to leave on:
|
|
1890
|
+
|
|
1891
|
+
- **The trigger is the version, not a tag.** Every push to `main` asks the registry whether
|
|
1892
|
+
`package.json`'s version exists; only when it does not does the release job run.
|
|
1893
|
+
- **There is no npm token.** npm's trusted publishing takes the job's OIDC token — the same
|
|
1894
|
+
one Fulcio certifies. The audit reaches Anthropic by workload identity federation with
|
|
1895
|
+
that token too, so the release job holds no long-lived secret at all.
|
|
1896
|
+
|
|
1897
|
+
The first release, 0.0.1, was published by hand and signed through sigstore as its
|
|
1898
|
+
maintainer; the workflow's baseline step accepts that identity alongside its own, so the
|
|
1899
|
+
chain of custody runs through it.
|
|
1900
|
+
|
|
1901
|
+
#### What this does not claim
|
|
1902
|
+
|
|
1903
|
+
An LLM review is a reviewer, not a proof. It raises the cost of slipping something past a
|
|
1904
|
+
release and does not reduce it to zero, and a gate that implied otherwise would be worse
|
|
1905
|
+
than no gate — which is what the `severity` and `summary` fields are for: they leave a
|
|
1906
|
+
record of what was judged acceptable and by what reasoning, so a later reader can disagree
|
|
1907
|
+
with it.
|
|
1908
|
+
|
|
1909
|
+
The gate also protects the publisher's pipeline only. A consumer who wants the same
|
|
1910
|
+
assurance runs the same skill over what they received. That is not a gap being papered
|
|
1911
|
+
over; it is the reason the skill is one review at two points rather than a build-only step.
|
|
1912
|
+
|
|
1913
|
+
#### Open questions
|
|
1914
|
+
|
|
1915
|
+
- **Whether a `fail` should open an issue** rather than only failing the run. Probably yes,
|
|
1916
|
+
and it is a workflow concern rather than a tool one.
|
|
1917
|
+
- **How far back a diff should reach.** The baseline is `@latest`, which is right for a
|
|
1918
|
+
normal release and wrong after a release that was itself under-reviewed — the diff would
|
|
1919
|
+
inherit that. A periodic full review, or diffing against the last *fully* reviewed
|
|
1920
|
+
release rather than the last one, would close it. Both need a record of which releases
|
|
1921
|
+
got which treatment, which the verdict file could carry but does not yet.
|
|
1922
|
+
- **Whether the gate belongs in `prepublishOnly` too.** Today it gates signing, which is
|
|
1923
|
+
the step that makes the claim. Publishing an already-signed artifact is arguably a
|
|
1924
|
+
separate decision, and arguably not.
|