@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/README.md
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
# @pipobscure/bundle
|
|
2
|
+
|
|
3
|
+
Ship a Node.js application as **one signed file** that the runtime refuses to run if it has
|
|
4
|
+
been tampered with.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bundle create --base ./app --files app.manifest --output app.bundle # archive it
|
|
8
|
+
bundle sign --launcher --output app.run app.bundle # sign, via sigstore
|
|
9
|
+
./app.run # and it is a program
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The archive is a real ZIP with a signature over the whole file. Mounting it through
|
|
13
|
+
`node:vfs` is what enforces that signature: the provider verifies before it returns a
|
|
14
|
+
filesystem, so an archive that does not check out never becomes one and its entry point
|
|
15
|
+
never runs. Every member is re-hashed against its signed digest as it is read, for the life
|
|
16
|
+
of the process.
|
|
17
|
+
|
|
18
|
+
> **Requires Node 26.10 or later**, run with `--experimental-vfs`. Every piece this needs is
|
|
19
|
+
> in a released Node; the last, the `--vfs-load` loader, shipped in v26.10.0. Building a
|
|
20
|
+
> single executable additionally needs one open pull request. See
|
|
21
|
+
> [Requirements](#requirements). Everything here is experimental.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
26
|
+
|
|
27
|
+
- [Install](#install) · [The four steps](#the-four-steps) · [CLI](#cli)
|
|
28
|
+
- [Using it from code](#using-it-from-code) · [Exports](#exports)
|
|
29
|
+
- [Executables that verify before they run](#executables-that-verify-before-they-run)
|
|
30
|
+
- [How it works](#how-it-works) · [What it does and does not prove](#what-it-does-and-does-not-prove)
|
|
31
|
+
- [Requirements](#requirements) · [Development](#development) · [Reading further](#reading-further)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install @pipobscure/bundle # the library
|
|
39
|
+
npx @pipobscure/bundle --help # the CLI, without installing
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The `bundle` command npm installs **is** the signed archive. `bin` points straight at
|
|
43
|
+
`bundle.run` — the CLI, its skill and its whole dependency tree in one file, behind a two-line
|
|
44
|
+
`#!/bin/sh` prefix that mounts it and runs it. There is no wrapper script in between, which
|
|
45
|
+
is the point: nothing unsigned stands between you and the artifact, and
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
head -c 100 "$(npm root)/@pipobscure/bundle/bundle.run" # what it will do
|
|
49
|
+
unzip -l "$(npm root)/@pipobscure/bundle/bundle.run" # everything it contains
|
|
50
|
+
bundle verify "$(npm root)/@pipobscure/bundle/bundle.run" # who signed it
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
answer every question about it without running anything. See
|
|
54
|
+
[the tool as a bundle of itself](HISTORY.md#where-this-ends-up-the-tool-as-a-bundle-of-itself).
|
|
55
|
+
|
|
56
|
+
Running it by name does not verify it — the kernel gives a `#!` launcher no preload to carry
|
|
57
|
+
a provider, and this package says so rather than pretending otherwise. Verification is a
|
|
58
|
+
separate act, done with a copy of `bundle` you already trust: `bundle verify bundle.run` to
|
|
59
|
+
check it, or `bundle run bundle.run -- <args>` to execute it through the verifying mount.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## The four steps
|
|
64
|
+
|
|
65
|
+
Building a bundle is four steps, in this order:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
1. observe run the application, and write down every file it actually reads
|
|
69
|
+
2. create archive exactly that list, unsigned
|
|
70
|
+
3. audit review it — against the last release, if there is one
|
|
71
|
+
4. sign only if step 3 came back clean
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 1. Observe
|
|
75
|
+
|
|
76
|
+
Static analysis is perennially wrong about dynamic `require`, data files and conditional
|
|
77
|
+
imports. So the file list comes from running the thing:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
|
|
81
|
+
-r @pipobscure/bundle/record --vfs-load=./app -- <args>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every file read through the mount is appended to `app.manifest`, one path per line, as it is
|
|
85
|
+
read — so a killed process still leaves a usable list. Read-only `open()`s count too, which
|
|
86
|
+
catches streamed files that a `readFile` hook would miss.
|
|
87
|
+
|
|
88
|
+
Observation has one blind spot worth knowing: code on a path the run never took. For a
|
|
89
|
+
dependency tree, pair it with a computed closure — see [`moduleFiles`](#using-it-from-code).
|
|
90
|
+
|
|
91
|
+
### 2. Create
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
bundle create --base ./app --files app.manifest --output app.bundle
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Unsigned, and deliberately so. This is the single input to every shape you ship.
|
|
98
|
+
|
|
99
|
+
### 3. Audit
|
|
100
|
+
|
|
101
|
+
A signature is a claim about bytes you stand behind, so the review belongs *before* it:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bundle audit app.bundle # what is about to be reviewed, and how
|
|
105
|
+
bundle skill # install the audit skill into .claude/skills/
|
|
106
|
+
claude "/audit-bundle app.bundle" # verify, extract, read every member
|
|
107
|
+
bundle audit --check app.bundle # exits non-zero without a clean verdict
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`bundle audit` does the two mechanical halves around the review. On its own it reports the
|
|
111
|
+
archive's hash, its members and — with `--baseline <previous>` — what changed since the last
|
|
112
|
+
release you approved. With `--check` it is a **gate**: it reads the JSON verdict the skill
|
|
113
|
+
writes and refuses unless that verdict passed *and* names the sha256 of the bytes on disk,
|
|
114
|
+
so rebuilding invalidates an approval. `--approve --note '<what you checked>'` records a
|
|
115
|
+
verdict you reached by reading the archive yourself.
|
|
116
|
+
|
|
117
|
+
There is deliberately no environment variable that turns the gate off. It is a command you
|
|
118
|
+
choose to put in your pipeline — if you do not want it, do not put it there.
|
|
119
|
+
|
|
120
|
+
[`audit-bundle`](skills/audit-bundle/SKILL.md) is a [Claude Code](https://claude.com/claude-code)
|
|
121
|
+
skill that verifies the archive, extracts it, and security-reviews every file — load-time
|
|
122
|
+
hooks, encoded payloads, outbound calls, credential and CI-token reads, `eval` and dynamic
|
|
123
|
+
`require`, and members nothing references. Because a bundle is a **closed set** — nothing
|
|
124
|
+
resolves later, nothing is fetched at install — the review can actually be complete.
|
|
125
|
+
|
|
126
|
+
It is the same review whoever receives the bundle should run before trusting it. That is the
|
|
127
|
+
point: hold your own artifact to the standard you would hold someone else's. It can also
|
|
128
|
+
review only the **diff** against a previously approved archive, which is the realistic
|
|
129
|
+
repeat-use case.
|
|
130
|
+
|
|
131
|
+
### 4. Sign
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
bundle audit --check app.bundle && bundle sign --launcher --output app.run app.bundle
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Through **sigstore** by default: an ambient CI identity when there is one, otherwise a
|
|
138
|
+
browser sign-in. No long-lived key exists to steal — the certificate lasts about ten
|
|
139
|
+
minutes, and a transparency-log entry and timestamp are what let it verify afterwards.
|
|
140
|
+
|
|
141
|
+
Or against a certificate authority of your own:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
bundle sign --key leaf.key --chain chain.pem --output app.signed.bundle app.bundle
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Signing is separate from building, and that is what makes one build serve every target:
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
bundle sign --launcher --output app.run app.bundle # a file you can run by name
|
|
151
|
+
bundle sea --output app.sea app.bundle # standalone executable
|
|
152
|
+
bundle sign --output app.signed.bundle app.bundle # plain, for a mount
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Each is correctly offset and signed over its own finished bytes. `--launcher` prepends the
|
|
156
|
+
shell prefix this package ships, so nobody has to know it lives inside `node_modules`;
|
|
157
|
+
`--prefix <file>` takes a launcher of your own, or a node binary.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## CLI
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
bundle <command> [options]
|
|
165
|
+
|
|
166
|
+
create build an archive from a list of files
|
|
167
|
+
sign sign an archive into a new file, optionally behind a prefix
|
|
168
|
+
audit report what is about to be reviewed, and gate signing on the verdict
|
|
169
|
+
verify verify an archive and report its trust state
|
|
170
|
+
run mount a signed archive and run it
|
|
171
|
+
sea build a node runtime that verifies an archive before running it
|
|
172
|
+
trust refresh the sigstore trust root
|
|
173
|
+
skill install the bundle-auditing skill into a project
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`bundle <command> --help` — or [`src/cli.ts`](src/cli.ts) — has every option. The ones worth
|
|
177
|
+
knowing:
|
|
178
|
+
|
|
179
|
+
### `verify`
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
bundle verify --root ca.pem --json app.bundle
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Reports one of four states, and exits with the matching code:
|
|
186
|
+
|
|
187
|
+
| State | Exit | Meaning |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `valid` | 0 | Hash, signature and every member digest are sound, and the chain is trusted. |
|
|
190
|
+
| `valid-untrusted` | 1 | All of that is sound; the certificate is not one you can place — or a sigstore trust root is missing, or a required identity did not match. |
|
|
191
|
+
| `unsigned` | 3 | No manifest, or a manifest with no signature. |
|
|
192
|
+
| `invalid` | 2 | The bytes changed since signing, a member's digest does not match its content, or the archive no longer parses as a ZIP at all. |
|
|
193
|
+
|
|
194
|
+
Note which side of the line "I could not check" falls on. Not being *able* to verify is
|
|
195
|
+
`valid-untrusted`, never `invalid` — conflating them is how people are trained to click
|
|
196
|
+
through warnings.
|
|
197
|
+
|
|
198
|
+
A certificate chain is trusted only for what it was issued for. The leaf must carry the
|
|
199
|
+
code-signing extended key usage, and everything above it must be a CA — otherwise the key
|
|
200
|
+
of any publicly trusted certificate, a web server's TLS certificate included, could sign an
|
|
201
|
+
archive that reads as `valid`. A root given with `--root` that *is* the leaf is trusted as
|
|
202
|
+
itself: that is pinning, and you chose it.
|
|
203
|
+
|
|
204
|
+
`--identity` and `--issuer` demand a particular sigstore signer, matched exactly — never as
|
|
205
|
+
a pattern. A mismatch is
|
|
206
|
+
`valid-untrusted`: the signature is genuine, it is simply not the one you asked for. An
|
|
207
|
+
archive signed against an ordinary CA carries no identity claim at all, so it also reads as
|
|
208
|
+
`valid-untrusted` under such a policy rather than passing.
|
|
209
|
+
|
|
210
|
+
### `audit`
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
bundle audit [--baseline <archive>] [--verdict <file>] [--check|--approve] <archive>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The gate described above. Exits non-zero when `--check` finds no verdict, a verdict over
|
|
217
|
+
different bytes, a verdict reached against a different baseline, or one that failed.
|
|
218
|
+
|
|
219
|
+
### `run`
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
bundle run --root ca.pem app.signed.bundle -- --your --app --args
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Re-execs Node with the preload and the mount, so what runs is what the child's own bootstrap
|
|
226
|
+
verified. Everything after `--` is the application's argv.
|
|
227
|
+
|
|
228
|
+
### `skill`
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
bundle skill # -> .claude/skills/audit-bundle/SKILL.md
|
|
232
|
+
bundle skill --list # what this package carries
|
|
233
|
+
bundle skill --dir <dir> --force
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Never overwrites a file that is already there unless forced, so local edits survive.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Using it from code
|
|
241
|
+
|
|
242
|
+
Everything the CLI does, as an API. The CLI is a `parseArgs` wrapper over exactly these
|
|
243
|
+
functions and holds no logic of its own.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import {
|
|
247
|
+
createBundle, signBundle, verifyBundle, inspectBundle, runBundle, fileSigner,
|
|
248
|
+
} from '@pipobscure/bundle';
|
|
249
|
+
|
|
250
|
+
// Build unsigned — the single input to every shape you ship.
|
|
251
|
+
await createBundle({ base: 'app/', files, output: 'app.bundle' });
|
|
252
|
+
|
|
253
|
+
// Sign, once per shape.
|
|
254
|
+
const signer = fileSigner({ key: 'leaf.key', chain: 'chain.pem' });
|
|
255
|
+
await signBundle({ source: 'app.bundle', output: 'app.run', prefix: 'shell-base', signer });
|
|
256
|
+
|
|
257
|
+
// Ask what it claims, and then whether any of it is true.
|
|
258
|
+
const { members, signed, hash } = inspectBundle('app.run');
|
|
259
|
+
const { state, reason, identity } = await verifyBundle('app.run', { roots: ['ca.pem'] });
|
|
260
|
+
|
|
261
|
+
// Mount it through the verifying provider, in a child process, and run it.
|
|
262
|
+
const { status } = runBundle('app.signed.bundle', { roots: ['ca.pem'], args: ['--help'] });
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Signers.** A signer is `{ chain, signAlg, sign(digest) }`. The chain goes into the archive
|
|
266
|
+
*before* hashing; `sign()` is called *after*, with the finished hash. That two-phase shape is
|
|
267
|
+
what lets sigstore work at all — the certificate has to be embedded before the bytes exist,
|
|
268
|
+
and the signature made after. `keySigner()` is the offline-CA implementation and
|
|
269
|
+
`@pipobscure/bundle/sigstore`'s `signer()` is the other one; a third (an HSM, a KMS, a
|
|
270
|
+
corporate signing service) is three properties away.
|
|
271
|
+
|
|
272
|
+
**Working out what to bundle.** `@pipobscure/bundle/recorder` observes a run;
|
|
273
|
+
`@pipobscure/bundle/files` computes a closure. Use both — the closure for completeness, the
|
|
274
|
+
observation as a cross-check:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { moduleFiles } from '@pipobscure/bundle/files';
|
|
278
|
+
|
|
279
|
+
const files = moduleFiles({
|
|
280
|
+
base: '.',
|
|
281
|
+
files: ['package.json'],
|
|
282
|
+
dirs: ['dist'],
|
|
283
|
+
dependencies: ['@sigstore/verify'], // and everything they depend on, transitively
|
|
284
|
+
filter: (name) => !name.endsWith('.map'),
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Registering the verifying provider yourself**, when the environment variables are not
|
|
289
|
+
enough:
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
// my-preload.js — node --experimental-vfs -r ./my-preload.js --vfs-load=app.bundle
|
|
293
|
+
import { register } from '@pipobscure/bundle/provider';
|
|
294
|
+
|
|
295
|
+
register({
|
|
296
|
+
extensions: ['.bundle', '.app'], // claimed by name
|
|
297
|
+
claimSigned: true, // and anything carrying a signature marker, whatever it is called
|
|
298
|
+
roots: ['/etc/ssl/my-root.pem'], // PEM text or paths to PEM files
|
|
299
|
+
allowUntrusted: false,
|
|
300
|
+
identity: 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main',
|
|
301
|
+
issuer: 'https://token.actions.githubusercontent.com',
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
A preload runs under the CommonJS loader, so it must contain no top-level `await`. ESM syntax
|
|
306
|
+
is otherwise fine, and `--import` works as well as `-r`.
|
|
307
|
+
|
|
308
|
+
### Environment
|
|
309
|
+
|
|
310
|
+
A preload takes no arguments, so the mount is configured through the environment:
|
|
311
|
+
|
|
312
|
+
| | |
|
|
313
|
+
|---|---|
|
|
314
|
+
| `BUNDLE_MANIFEST` | where the recording provider writes the observed file list |
|
|
315
|
+
| `BUNDLE_ROOTS` | extra trusted roots, a path-delimiter-separated list of PEM files |
|
|
316
|
+
| `BUNDLE_ALLOW_UNTRUSTED` | mount an archive whose signature is good but unanchored |
|
|
317
|
+
| `BUNDLE_IDENTITY` / `BUNDLE_ISSUER` | require a particular sigstore signer at mount time |
|
|
318
|
+
| `BUNDLE_SIGSTORE_ROOT` | the sigstore trust root to check against, instead of the cache |
|
|
319
|
+
| `BUNDLE_NO_BROWSER` | never try to open a browser when signing; use the device flow |
|
|
320
|
+
| `BUNDLE_AUDIT_VERDICT` | where the audit skill writes its verdict, when CI asks for one |
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Exports
|
|
325
|
+
|
|
326
|
+
```jsonc
|
|
327
|
+
{
|
|
328
|
+
".": "create / sign / verify / inspect / run, from code",
|
|
329
|
+
"./register": "-r preload: mount only what is signed",
|
|
330
|
+
"./record": "-r preload: write down what a run reads",
|
|
331
|
+
"./sea": "build a verifying runtime, with or without an app inside",
|
|
332
|
+
"./launch": "verify a container, mount it, run it — and the runtime's CLI",
|
|
333
|
+
"./provider": "the verifying provider, and register(options)",
|
|
334
|
+
"./recorder": "the recording provider, and recording(Base, manifest)",
|
|
335
|
+
"./cli": "main(argv, io) -> exit code",
|
|
336
|
+
"./manifest": "the archive format on its own",
|
|
337
|
+
"./archive": "bundling and re-emitting",
|
|
338
|
+
"./files": "dependency closures",
|
|
339
|
+
"./skill": "the shipped skills, and installing them",
|
|
340
|
+
"./audit": "the audit gate: prepare, check, approve",
|
|
341
|
+
"./sigstore": "the sigstore signer and bundle verification",
|
|
342
|
+
"./oidc": "identity tokens: CI, browser, or device code"
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The package root deliberately does **not** re-export the two providers: importing either
|
|
347
|
+
needs `node:vfs`, and creating or verifying an archive does not, so `import
|
|
348
|
+
'@pipobscure/bundle'` must not drag that requirement in.
|
|
349
|
+
|
|
350
|
+
Written in TypeScript, published as ESM with declarations. The sources use erasable syntax
|
|
351
|
+
only, so `node src/main.ts` runs them directly under Node's type stripping.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Executables that verify before they run
|
|
356
|
+
|
|
357
|
+
`bundle sea` builds a node runtime with this package inside it. What you do with that runtime
|
|
358
|
+
is the difference between the two shapes it can take.
|
|
359
|
+
|
|
360
|
+
**With an archive, it becomes that application** — one file that checks its own signature
|
|
361
|
+
before running anything:
|
|
362
|
+
|
|
363
|
+
```
|
|
364
|
+
[ node runtime | SEA blob: stub + the verifier, as a mounted archive ] [ app.bundle ]
|
|
365
|
+
\_______________________ the prefix, and part of the _______________/
|
|
366
|
+
\______________________ archive's signed region ______/
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
```sh
|
|
370
|
+
bundle sea --output app.sea \
|
|
371
|
+
--root /etc/ssl/my-root.pem \
|
|
372
|
+
--identity 'https://github.com/me/app/.github/workflows/release.yml@refs/heads/main' \
|
|
373
|
+
--issuer 'https://token.actions.githubusercontent.com' \
|
|
374
|
+
app.bundle
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
The whole-file hash covers the prefix too, so the runtime and the verifier inside it are
|
|
378
|
+
signed by the same signature that covers the application. There is nothing to check the
|
|
379
|
+
checker against, because the checker is inside what is checked.
|
|
380
|
+
|
|
381
|
+
**Without one, it becomes a verifying node** — a runtime that takes an archive on its command
|
|
382
|
+
line, checks it, and runs it:
|
|
383
|
+
|
|
384
|
+
```sh
|
|
385
|
+
bundle sea --output node-verifying --root /etc/ssl/my-root.pem
|
|
386
|
+
./node-verifying ./my-app.zip --args --for --the --app
|
|
387
|
+
./node-verifying --verify ./my-app.zip # the trust state, without running it
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
One runtime, any number of applications, none of them trusted until they verify. The
|
|
391
|
+
application sees the argv it would have had from `--vfs-load`: the archive where a script
|
|
392
|
+
path goes, its own arguments from index 2 on, and none of the runtime's flags — which is why
|
|
393
|
+
everything after the archive belongs to the program, `--help` included.
|
|
394
|
+
|
|
395
|
+
The two are the same binary. A verifying node with an archive appended to it — `bundle sign
|
|
396
|
+
--prefix node-verifying app.bundle` — *is* the self-validating executable, and at startup the
|
|
397
|
+
runtime decides which it is by looking at its own tail: a signed archive behind it runs that,
|
|
398
|
+
nothing behind it takes one from the command line, and an *unsigned* archive behind it is
|
|
399
|
+
refused rather than quietly treated as neither.
|
|
400
|
+
|
|
401
|
+
**Policy is baked in, or it is not.** The `--root`, `--identity` and `--issuer` given at build
|
|
402
|
+
time become the executable's own policy — the point being that a binary run by its own name
|
|
403
|
+
has no flags and no preload to configure it. A runtime built with a policy is **sealed**: it
|
|
404
|
+
takes no policy from its command line, because a binary that demands a signing identity is
|
|
405
|
+
not one whose user can ask it to stop. Build without one and the flags above work, falling
|
|
406
|
+
back to `BUNDLE_ROOTS` and friends, so one build can be decided about later.
|
|
407
|
+
|
|
408
|
+
From code, `createSeaBase()` and `buildSea()` split the expensive half (a ~155 MB copy of
|
|
409
|
+
Node) from the cheap one, `@pipobscure/bundle/launch` is the entry point all of this runs
|
|
410
|
+
through — `run()`, `runSelf()`, `verify()`, `main()` — and `verifySelf()` lets an application
|
|
411
|
+
report on its own provenance. The package rides inside the executable as an archive that node
|
|
412
|
+
mounts for itself: `"useVfs": true` ([nodejs/node#65675](https://github.com/nodejs/node/pull/65675),
|
|
413
|
+
released in v26.9.0) with `"vfsArchive"` ([nodejs/node#65810](https://github.com/nodejs/node/pull/65810),
|
|
414
|
+
still open), which is why the generated stub is a handful of lines and why there is no second
|
|
415
|
+
copy of the verifier anywhere.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## How it works
|
|
420
|
+
|
|
421
|
+
**The archive** is a ZIP. Its members each carry the hex digest of their own content in the
|
|
422
|
+
ZIP entry comment. A final `AUTHORITY.PEM` member declares the algorithms and carries the
|
|
423
|
+
signing certificate chain — a real, extractable filename, so `unzip` plus `openssl x509`
|
|
424
|
+
tells you who signed something without any of this code.
|
|
425
|
+
|
|
426
|
+
**The signature** covers the *entire file*: any prefix, every member, the whole central
|
|
427
|
+
directory, and the fixed part of the end-of-central-directory record — everything up to the
|
|
428
|
+
EOCD's trailing comment. That comment then records both:
|
|
429
|
+
|
|
430
|
+
```
|
|
431
|
+
[ prefix | members | AUTHORITY.PEM | central directory | EOCD ] [ comment ]
|
|
432
|
+
\_____________ hashed region → H ______________________/ SIGNED:H:S[:FIELD=…]
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Staged deliberately. The hash alone is a cheap, certificate-free integrity gate you can run
|
|
436
|
+
before deciding to mount anything; only then is the signature over that hash checked against
|
|
437
|
+
the leaf certificate; only then is the chain anchored. Because the hash covers the central
|
|
438
|
+
directory, it fixes *which* members exist and what each one's digest is, so changing any byte
|
|
439
|
+
after signing yields `invalid`.
|
|
440
|
+
|
|
441
|
+
The comment sits outside the hash on purpose: it is the unsigned-attribute region every
|
|
442
|
+
code-signing scheme eventually grows. Anything obtained *after* the signature exists cannot
|
|
443
|
+
be inside what the signature covers — which is where the sigstore bundle rides, carrying the
|
|
444
|
+
transparency-log entry and timestamp that establish *when* a ten-minute certificate was
|
|
445
|
+
valid. RFC 3161 puts timestamp tokens in CMS `unsignedAttrs` for exactly this reason.
|
|
446
|
+
|
|
447
|
+
**The mount** is where it stops being advisory. `--vfs-load` asks registered providers who
|
|
448
|
+
wants its source; this package's provider claims `.bundle` files by name and any file carrying
|
|
449
|
+
a signature marker by content — so renaming a signed archive cannot quietly downgrade it to
|
|
450
|
+
the unchecked built-in ZIP provider. It verifies before returning a filesystem, and re-hashes
|
|
451
|
+
each member as it is first read, because a `ZipFile` reads lazily from an open descriptor and
|
|
452
|
+
a file rewritten underneath a running program would otherwise be served unchecked.
|
|
453
|
+
|
|
454
|
+
**Prefixes.** ZIP offsets are absolute, so an archive can sit *after* arbitrary bytes and
|
|
455
|
+
still be a valid ZIP — which is what lets one build become a `#!` launcher, a native
|
|
456
|
+
executable, or a plain mountable archive. The prefix has to be chosen before offsets are
|
|
457
|
+
fixed, and therefore before the hash exists, which is exactly why signing re-emits an archive
|
|
458
|
+
rather than appending to one.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## What it does and does not prove
|
|
463
|
+
|
|
464
|
+
**It proves provenance.** The code is the code that was signed, by someone holding that
|
|
465
|
+
certificate, and the runtime enforces it rather than the application checking itself.
|
|
466
|
+
|
|
467
|
+
**It does not prove safety.** Every significant npm compromise of recent years shipped a
|
|
468
|
+
correctly published, correctly signed package from a legitimately compromised account. A
|
|
469
|
+
signature would have confirmed it came from the real maintainer and been useless. That is
|
|
470
|
+
what step 3 is for, and why it is a separate step performed by a reviewer rather than a
|
|
471
|
+
property of the format.
|
|
472
|
+
|
|
473
|
+
Other limits, stated plainly:
|
|
474
|
+
|
|
475
|
+
- **VFS is not a sandbox.** It redirects `fs` calls; it does not confine untrusted code.
|
|
476
|
+
Verified code runs with the full authority of the process.
|
|
477
|
+
- **The gate is only as strong as how Node was launched.** Anyone who can change the command
|
|
478
|
+
line can drop the `-r`, and the mount falls back to the built-in provider, which checks
|
|
479
|
+
nothing. Registration is a userland opt-in, not a runtime policy. A SEA closes this for
|
|
480
|
+
itself by carrying its own bootstrap.
|
|
481
|
+
- **A shebang archive does not self-verify.** The kernel gives it no preload flag to carry a
|
|
482
|
+
provider. Mount it with the preload, or use a SEA.
|
|
483
|
+
- **A sigstore signature is public.** Signing puts your identity, the archive's hash and the
|
|
484
|
+
time in an append-only log. That is the mechanism working — it is what makes the
|
|
485
|
+
ten-minute certificate verifiable later — not something to discover afterwards.
|
|
486
|
+
- **Everything here is experimental**, including the Node it needs.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## Requirements
|
|
491
|
+
|
|
492
|
+
Everything here sits on Node's experimental `node:vfs` (by Matteo Collina) and runs under
|
|
493
|
+
`--experimental-vfs`. Where each piece stands, as of 2026-09-02:
|
|
494
|
+
|
|
495
|
+
| Piece | Where it is |
|
|
496
|
+
|---|---|
|
|
497
|
+
| **`node:vfs`**, and modules resolving and loading out of a mount | released, v26.4.0 |
|
|
498
|
+
| **ZIP support in `node:zlib`** — `ZipFile`, `ZipBuffer`, `ZipEntry` | released, v26.8.0 |
|
|
499
|
+
| **`ZipProvider`**, a VFS provider backed by such an archive | released, v26.9.0 — [nodejs/node#64915](https://github.com/nodejs/node/pull/64915) |
|
|
500
|
+
| **Native addons loaded from a mount** | released, v26.9.0 — [nodejs/node#65680](https://github.com/nodejs/node/pull/65680) |
|
|
501
|
+
| **`"useVfs"`**, a SEA's assets behind a VFS mount | released, v26.9.0 — [nodejs/node#65675](https://github.com/nodejs/node/pull/65675) |
|
|
502
|
+
| **`--vfs-load`**, and `vfs.registerProvider()` | released, v26.10.0 — [nodejs/node#65748](https://github.com/nodejs/node/pull/65748) |
|
|
503
|
+
| **`"vfsArchive"`**, a ZIP as a SEA's file system — `bundle sea` only | open — [nodejs/node#65810](https://github.com/nodejs/node/pull/65810) |
|
|
504
|
+
|
|
505
|
+
v26.9.0 already had everything a program needs to *be* an archive: it reads ZIP archives,
|
|
506
|
+
turns one into a file system, resolves modules out of it, and loads native addons from it.
|
|
507
|
+
v26.10.0 added the way to ask for that mount from *outside* the program, which is the whole
|
|
508
|
+
hinge: **`--vfs-load`** makes a mounted tree the thing a program resolves and runs from, and
|
|
509
|
+
the same pull request brings `vfs.registerProvider()` — the extension point that lets a
|
|
510
|
+
preload decide what backs a mount, and therefore the one that makes a *verifying* mount
|
|
511
|
+
possible from userland at all.
|
|
512
|
+
|
|
513
|
+
**`--vfs-load` is the only flag.** v26.10.0 also shipped `--vfs-mount`, which mounted a source
|
|
514
|
+
without running it, and the next patch release removes it
|
|
515
|
+
([nodejs/node#66162](https://github.com/nodejs/node/pull/66162)): nothing needs more than one
|
|
516
|
+
mount from the command line, and a program that wants more mounts them through `node:vfs`,
|
|
517
|
+
where it also holds the instance. The same change reserves layer 0 for the `--vfs-load`
|
|
518
|
+
source, so it sits at the same mount point in every thread whatever else is mounted, and
|
|
519
|
+
mounts a program makes itself are numbered from 1. Nothing here uses `--vfs-mount`, and
|
|
520
|
+
mount points stay node's to assign: named mounts
|
|
521
|
+
([nodejs/node#66119](https://github.com/nodejs/node/pull/66119)) were closed rather than
|
|
522
|
+
merged.
|
|
523
|
+
|
|
524
|
+
[nodejs/node#65810](https://github.com/nodejs/node/pull/65810) is needed only to build an
|
|
525
|
+
executable. It lets a SEA's file system *be* a ZIP archive rather than a list of assets, which
|
|
526
|
+
is how this package gets inside one: `bundle sea` embeds the verifier bundle whole and node
|
|
527
|
+
mounts it. It is still open; everything else here works without it.
|
|
528
|
+
|
|
529
|
+
Native addons out of a mount shipped in v26.9.0, as
|
|
530
|
+
[nodejs/node#65680](https://github.com/nodejs/node/pull/65680), which closed the last gap in
|
|
531
|
+
what a bundle can contain. A `dlopen()` needs a path with an inode behind it and a VFS path
|
|
532
|
+
has none, so it reads the addon's bytes out of the mount and loads them from a private,
|
|
533
|
+
self-cleaning image instead — an anonymous memfd on Linux, an unlinked temp file elsewhere.
|
|
534
|
+
Before it, a bundle whose dependency tree included a `.node` file mounted fine and then failed
|
|
535
|
+
at `require`.
|
|
536
|
+
|
|
537
|
+
[HISTORY.md](HISTORY.md) explains each in detail and why they are worth having.
|
|
538
|
+
|
|
539
|
+
`openssl` on `PATH` is needed only to generate the throwaway PKI the tests use.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## Development
|
|
544
|
+
|
|
545
|
+
```sh
|
|
546
|
+
npm install
|
|
547
|
+
npm run build # TypeScript -> dist/, with declarations
|
|
548
|
+
npm test # 145 tests; generates a throwaway PKI into build/certs/ on first run
|
|
549
|
+
npm run typecheck
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
The suite needs Node 26.10 or later. The sixteen tests that build an executable also need
|
|
553
|
+
[nodejs/node#65810](https://github.com/nodejs/node/pull/65810); on a Node without it they skip
|
|
554
|
+
themselves and say why, and they run on the first Node that has it. [CI](.github/workflows/ci.yml)
|
|
555
|
+
runs the suite on every push to `main` and every pull request, on 26.10.0 — the floor
|
|
556
|
+
`package.json` promises.
|
|
557
|
+
|
|
558
|
+
Tests import the sources rather than the build, so they run under Node's type stripping. The
|
|
559
|
+
test PKI is generated on demand by `tools/testpki.ts` and is **never committed** — a private
|
|
560
|
+
key in a repository is a private key people sign with, and it would produce signatures that
|
|
561
|
+
look like provenance and carry none.
|
|
562
|
+
|
|
563
|
+
Building the tool the way the tool says to build things — the same four steps:
|
|
564
|
+
|
|
565
|
+
```sh
|
|
566
|
+
npm run release:cli # 1-3: observe, pack, fetch the baseline, stop at the gate
|
|
567
|
+
npm run sign:cli:local # 4: refuses — nothing has been audited yet
|
|
568
|
+
BUNDLE_AUDIT_VERDICT=build/cli.audit.json claude "/audit-bundle build/cli.bundle"
|
|
569
|
+
npm run sign:cli:local # 4: now allowed -> bundle.run
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
| Script | |
|
|
573
|
+
|---|---|
|
|
574
|
+
| `manifest:cli` | observe a run, close over the dependencies, write the file list |
|
|
575
|
+
| `pack:cli` | `bundle create` over that list |
|
|
576
|
+
| `baseline:cli` | fetch and verify the published release, to review against |
|
|
577
|
+
| `audit:cli` | `bundle audit` — report the diff and print the skill invocation |
|
|
578
|
+
| `approve:cli` | `bundle audit --approve` |
|
|
579
|
+
| `sign:cli` | `bundle audit --check`, then `bundle sign --launcher` through sigstore |
|
|
580
|
+
| `release:cli` | steps 1–3, stopping at the gate |
|
|
581
|
+
|
|
582
|
+
Only `manifest:cli` and `baseline:cli` are scripts of their own; the rest are the CLI. The
|
|
583
|
+
first observes a run and computes a dependency closure, the second fetches this package's
|
|
584
|
+
own published release from npm — both specific to how *this* project is built.
|
|
585
|
+
|
|
586
|
+
**The gate is real, and it is a shipped command** — `bundle audit --check`, not repo
|
|
587
|
+
tooling. It runs before signing, reads the JSON verdict the skill writes, and refuses unless
|
|
588
|
+
that verdict passed *and* pins the sha256 of the bytes on disk. Everything this repository
|
|
589
|
+
does to release itself is something you can do to your own project.
|
|
590
|
+
|
|
591
|
+
[`.github/workflows/publish.yml`](.github/workflows/publish.yml) is the whole pipeline as a
|
|
592
|
+
workflow — test, pack, fetch the published release, audit the diff, gate, sign through
|
|
593
|
+
sigstore with the workflow's OIDC identity, publish through npm trusted publishing, every
|
|
594
|
+
action pinned to a commit SHA. It runs on every push to `main` and does nothing unless
|
|
595
|
+
`package.json` names a version npm does not have yet: bumping the version *is* the release.
|
|
596
|
+
There is no npm token anywhere; npm trusts that workflow file by name.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## Reading further
|
|
601
|
+
|
|
602
|
+
- **[HISTORY.md](HISTORY.md)** — why this exists, what changed in Node and why those changes
|
|
603
|
+
make sense, and the experiment that produced the tool. The long-form argument, with the
|
|
604
|
+
implementation notes at the end.
|
|
605
|
+
- **[HISTORY.md § Implementation notes](HISTORY.md#implementation-notes)** — design
|
|
606
|
+
decided before it was built, and what departed from the plan: signing-time attestation,
|
|
607
|
+
the audit skill, shipping the tool as a bundle of itself, the self-validating executable,
|
|
608
|
+
and the audit as a build step.
|
|
609
|
+
- **[examples/static-server/](examples/static-server/)** — an example application: a static web
|
|
610
|
+
server that serves the directories and archives it is handed, built and signed the way this
|
|
611
|
+
README says to build things. It ships with the repository, not with the package.
|
|
612
|
+
- **[skills/audit-bundle/SKILL.md](skills/audit-bundle/SKILL.md)** — the review procedure.
|
|
613
|
+
- **[slides/](slides/)** — *Ship the Tree*, a talk about the project, kept in step with it.
|
|
614
|
+
`slides/index.html` opens in any browser with no build step; press <kbd>S</kbd> for the
|
|
615
|
+
speaker notes, which carry most of the argument.
|
|
616
|
+
**[Read it here](https://claude.ai/artifact/FsAKxG5e5rKYaUXArqb6Us)** —
|
|
617
|
+
same deck, published.
|
|
618
|
+
|
|
619
|
+
---
|
|
620
|
+
|
|
621
|
+
## License
|
|
622
|
+
|
|
623
|
+
[EUPL-1.2](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12)
|
package/bundle.run
ADDED
|
Binary file
|