sandboxedjs 0.1.94 → 0.1.97

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.
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build and sign a `.sbjs` application package.
4
+ *
5
+ * sandboxedjs-pack keygen --out publisher.key --publisher "Your Name"
6
+ * sandboxedjs-pack build ./app-dir --out officesuite-0.1.0.sbjs --key publisher.key
7
+ * sandboxedjs-pack inspect officesuite-0.1.0.sbjs
8
+ *
9
+ * The directory must contain an `app.json` describing the application. What
10
+ * the packer adds is `META/manifest.json` — a sha256 for every file — and
11
+ * `META/signature.json`, an Ed25519 signature over that manifest's bytes.
12
+ *
13
+ * The private key never leaves the machine that signs, and is written with
14
+ * mode 600. There is no key recovery: a lost signing key means publishing
15
+ * under a new one, which every container then has to be told to trust.
16
+ */
17
+
18
+ import { createHash, generateKeyPairSync, sign as signBytes, createPrivateKey, createPublicKey } from "node:crypto";
19
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
20
+ import { basename, dirname, join, relative, resolve } from "node:path";
21
+ import { deflateRawSync, inflateRawSync } from "node:zlib";
22
+
23
+ const [command, ...rest] = process.argv.slice(2);
24
+ const flags = parseFlags(rest);
25
+
26
+ try {
27
+ switch (command) {
28
+ case "keygen": await keygen(); break;
29
+ case "build": await build(); break;
30
+ case "inspect": await inspect(); break;
31
+ case "help": case "--help": case "-h": case undefined: usage(); break;
32
+ default:
33
+ console.error(`sandboxedjs-pack: unknown command ${command}`);
34
+ usage();
35
+ process.exit(2);
36
+ }
37
+ } catch (error) {
38
+ console.error(`sandboxedjs-pack: ${error.message}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ function usage() {
43
+ console.log(`sandboxedjs-pack — build and sign .sbjs application packages
44
+
45
+ keygen --out KEY --publisher NAME
46
+ build DIR --out FILE.sbjs --key KEY [--unsigned]
47
+ inspect FILE.sbjs
48
+
49
+ A .sbjs is a zip: the application's files, plus META/manifest.json listing a
50
+ sha256 for each of them, plus META/signature.json signing that manifest.
51
+ \`pm\` checks both before installing, and refuses a package whose signing key
52
+ the container does not trust.`);
53
+ }
54
+
55
+ // ------------------------------------------------------------------ keygen
56
+
57
+ async function keygen() {
58
+ const out = flags.out ?? "publisher.key";
59
+ const publisher = flags.publisher;
60
+ if (!publisher) throw new Error("keygen needs --publisher, the name packages are signed as");
61
+
62
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
63
+ const secret = {
64
+ format: "sandboxedjs-publisher-key",
65
+ publisher,
66
+ privateKey: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
67
+ publicKey: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
68
+ createdAt: new Date().toISOString(),
69
+ };
70
+ writeFileSync(out, `${JSON.stringify(secret, null, 2)}\n`, { mode: 0o600 });
71
+
72
+ const print = fingerprint(secret.publicKey);
73
+ console.log(`Wrote ${out} (keep it secret; there is no recovery)`);
74
+ console.log(`publisher: ${publisher}`);
75
+ console.log(`fingerprint: ${print}`);
76
+ console.log(`public key: ${secret.publicKey}`);
77
+ console.log("");
78
+ console.log("Containers trust it with:");
79
+ console.log(` pm trust add ${JSON.stringify(publisher)} ${secret.publicKey}`);
80
+ }
81
+
82
+ // ------------------------------------------------------------------- build
83
+
84
+ async function build() {
85
+ const directory = resolve(rest.find((argument) => !argument.startsWith("--")) ?? ".");
86
+ const manifestPath = join(directory, "app.json");
87
+ let app;
88
+ try {
89
+ app = JSON.parse(readFileSync(manifestPath, "utf8"));
90
+ } catch (error) {
91
+ throw new Error(`cannot read ${manifestPath}: ${error.message}`);
92
+ }
93
+ for (const field of ["name", "version"]) {
94
+ if (typeof app[field] !== "string" || !app[field]) {
95
+ throw new Error(`app.json has no ${field}`);
96
+ }
97
+ }
98
+
99
+ const files = collect(directory).filter((path) => !path.startsWith("META/"));
100
+ if (!files.includes("app.json")) throw new Error("app.json must be in the package");
101
+ for (const binary of app.bins ?? []) {
102
+ if (!files.includes(binary.path)) {
103
+ /* Caught here rather than at install time, where the package is already
104
+ * on someone else's machine. */
105
+ throw new Error(`app.json declares the command ${binary.name} at ${binary.path}, which is not in ${directory}`);
106
+ }
107
+ }
108
+
109
+ const digests = {};
110
+ const contents = new Map();
111
+ for (const path of files) {
112
+ const bytes = readFileSync(join(directory, path));
113
+ contents.set(path, bytes);
114
+ digests[path] = createHash("sha256").update(bytes).digest("hex");
115
+ }
116
+
117
+ const manifest = {
118
+ format: "sandboxedjs-package",
119
+ schemaVersion: 1,
120
+ app,
121
+ files: digests,
122
+ built: new Date().toISOString(),
123
+ };
124
+ /* The signature covers these exact bytes, so they are written once and used
125
+ * both for signing and for the archive. Re-serialising before signing would
126
+ * risk signing a different string than the one shipped. */
127
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
128
+ contents.set("META/manifest.json", manifestBytes);
129
+
130
+ if (!flags.unsigned) {
131
+ const keyPath = flags.key;
132
+ if (!keyPath) {
133
+ throw new Error("build needs --key, or --unsigned to publish without a publisher");
134
+ }
135
+ const key = JSON.parse(readFileSync(keyPath, "utf8"));
136
+ const privateKey = createPrivateKey({
137
+ key: Buffer.from(key.privateKey, "base64"), format: "der", type: "pkcs8",
138
+ });
139
+ const signature = {
140
+ format: "sandboxedjs-signature",
141
+ schemaVersion: 1,
142
+ algorithm: "ed25519",
143
+ publisher: key.publisher,
144
+ publicKey: key.publicKey
145
+ ?? createPublicKey(privateKey).export({ type: "spki", format: "der" }).toString("base64"),
146
+ signature: signBytes(null, manifestBytes, privateKey).toString("base64"),
147
+ };
148
+ contents.set("META/signature.json", Buffer.from(`${JSON.stringify(signature, null, 2)}\n`));
149
+ }
150
+
151
+ const out = flags.out ?? `${app.name}-${app.version}.sbjs`;
152
+ mkdirSync(dirname(resolve(out)), { recursive: true });
153
+ const archive = zip(contents, new Set((app.bins ?? []).map((binary) => binary.path)));
154
+ writeFileSync(out, archive);
155
+
156
+ const digest = createHash("sha256").update(archive).digest("hex");
157
+ console.log(`${out} ${(archive.length / 1024).toFixed(1)} kB`);
158
+ console.log(` ${app.name} ${app.version}, ${files.length} files`);
159
+ console.log(` sha256: ${digest}`);
160
+ console.log(flags.unsigned ? " unsigned" : ` signed by ${JSON.parse(readFileSync(flags.key, "utf8")).publisher}`);
161
+ /* What the registry index needs, so publishing is copy and paste. */
162
+ console.log("\nRegistry entry:");
163
+ console.log(JSON.stringify({
164
+ name: app.name, version: app.version, summary: app.summary,
165
+ filename: basename(out), sha256: digest, size: archive.length,
166
+ bins: app.bins ?? [], services: app.services ?? [], requires: app.requires ?? [],
167
+ docs: app.docs, license: app.license,
168
+ }, null, 2));
169
+ }
170
+
171
+ // ----------------------------------------------------------------- inspect
172
+
173
+ async function inspect() {
174
+ const file = rest.find((argument) => !argument.startsWith("--"));
175
+ if (!file) throw new Error("inspect needs a .sbjs file");
176
+ const bytes = readFileSync(file);
177
+ const entries = readZip(bytes);
178
+ const manifestEntry = entries.find((entry) => entry.name === "META/manifest.json");
179
+ if (!manifestEntry) throw new Error(`${file} has no META/manifest.json`);
180
+ const manifest = JSON.parse(manifestEntry.data.toString("utf8"));
181
+ const signatureEntry = entries.find((entry) => entry.name === "META/signature.json");
182
+
183
+ console.log(`${manifest.app.name} ${manifest.app.version}`);
184
+ if (manifest.app.summary) console.log(` ${manifest.app.summary}`);
185
+ console.log(` built: ${manifest.built ?? "unknown"}`);
186
+ console.log(` files: ${Object.keys(manifest.files).length}`);
187
+
188
+ let mismatched = 0;
189
+ for (const entry of entries) {
190
+ if (entry.name.startsWith("META/")) continue;
191
+ const actual = createHash("sha256").update(entry.data).digest("hex");
192
+ if (manifest.files[entry.name] !== actual) {
193
+ console.log(` ALTERED: ${entry.name}`);
194
+ mismatched += 1;
195
+ }
196
+ }
197
+ console.log(mismatched === 0 ? " every file matches the manifest" : ` ${mismatched} files do not match`);
198
+
199
+ if (!signatureEntry) {
200
+ console.log(" unsigned: this package has no publisher");
201
+ return;
202
+ }
203
+ const signature = JSON.parse(signatureEntry.data.toString("utf8"));
204
+ console.log(` signed by: ${signature.publisher ?? "unnamed"}`);
205
+ console.log(` fingerprint: ${fingerprint(signature.publicKey)}`);
206
+ const { verify } = await import("node:crypto");
207
+ const valid = verify(
208
+ null, manifestEntry.data,
209
+ createPublicKey({ key: Buffer.from(signature.publicKey, "base64"), format: "der", type: "spki" }),
210
+ Buffer.from(signature.signature, "base64"),
211
+ );
212
+ console.log(valid ? " signature: valid" : " signature: DOES NOT MATCH");
213
+ if (!valid || mismatched > 0) process.exitCode = 1;
214
+ }
215
+
216
+ // ------------------------------------------------------------------ pieces
217
+
218
+ function collect(root, prefix = "") {
219
+ const out = [];
220
+ for (const entry of readdirSync(join(root, prefix), { withFileTypes: true })) {
221
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
222
+ if (entry.isDirectory()) {
223
+ out.push(...collect(root, path));
224
+ } else if (entry.isFile()) {
225
+ out.push(path);
226
+ }
227
+ }
228
+ return out.sort();
229
+ }
230
+
231
+ function fingerprint(publicKey) {
232
+ return createHash("sha256")
233
+ .update(Buffer.from(publicKey, "base64"))
234
+ .digest("hex")
235
+ .slice(0, 32)
236
+ .replace(/(.{4})(?=.)/g, "$1-");
237
+ }
238
+
239
+ function parseFlags(args) {
240
+ const flags = {};
241
+ for (let index = 0; index < args.length; index += 1) {
242
+ const argument = args[index];
243
+ if (!argument.startsWith("--")) continue;
244
+ const name = argument.slice(2);
245
+ if (name.includes("=")) {
246
+ const [key, value] = name.split("=");
247
+ flags[key] = value;
248
+ } else if (args[index + 1] && !args[index + 1].startsWith("--")) {
249
+ flags[name] = args[index + 1];
250
+ index += 1;
251
+ } else {
252
+ flags[name] = true;
253
+ }
254
+ }
255
+ return flags;
256
+ }
257
+
258
+ /** A zip with deflate, and the executable bit on declared binaries. */
259
+ function zip(contents, executables) {
260
+ const locals = [];
261
+ const central = [];
262
+ let offset = 0;
263
+
264
+ for (const [name, data] of contents) {
265
+ const nameBytes = Buffer.from(name, "utf8");
266
+ const compressed = deflateRawSync(data);
267
+ const crc = crc32(data);
268
+ const mode = executables.has(name) ? 0o100755 : 0o100644;
269
+
270
+ const local = Buffer.alloc(30 + nameBytes.length);
271
+ /* Offsets are the zip specification's, exactly: writing the CRC two bytes
272
+ * late put it where the compressed size belongs, which our own reader
273
+ * ignored and `unzip` rejected as "bad CRC". */
274
+ local.writeUInt32LE(0x04034b50, 0); // signature
275
+ local.writeUInt16LE(20, 4); // version needed
276
+ local.writeUInt16LE(0, 6); // flags
277
+ local.writeUInt16LE(8, 8); // deflate
278
+ local.writeUInt16LE(0x0021, 10); // a fixed time, so two builds of the
279
+ local.writeUInt16LE(0x5a21, 12); // same input produce the same package
280
+ local.writeUInt32LE(crc, 14);
281
+ local.writeUInt32LE(compressed.length, 18);
282
+ local.writeUInt32LE(data.length, 22);
283
+ local.writeUInt16LE(nameBytes.length, 26);
284
+ local.writeUInt16LE(0, 28); // extra length
285
+ nameBytes.copy(local, 30);
286
+ locals.push(local, compressed);
287
+
288
+ const entry = Buffer.alloc(46 + nameBytes.length);
289
+ entry.writeUInt32LE(0x02014b50, 0); // signature
290
+ entry.writeUInt16LE(0x031e, 4); // made by: unix, so the mode is read
291
+ entry.writeUInt16LE(20, 6); // version needed
292
+ entry.writeUInt16LE(0, 8); // flags
293
+ entry.writeUInt16LE(8, 10); // deflate
294
+ entry.writeUInt16LE(0x0021, 12); // time
295
+ entry.writeUInt16LE(0x5a21, 14); // date
296
+ entry.writeUInt32LE(crc, 16);
297
+ entry.writeUInt32LE(compressed.length, 20);
298
+ entry.writeUInt32LE(data.length, 24);
299
+ entry.writeUInt16LE(nameBytes.length, 28);
300
+ entry.writeUInt16LE(0, 30); // extra length
301
+ entry.writeUInt16LE(0, 32); // comment length
302
+ entry.writeUInt16LE(0, 34); // disk number
303
+ entry.writeUInt16LE(0, 36); // internal attributes
304
+ /* External attributes: the unix mode lives in the high 16 bits, and
305
+ * `<< 16` overflows a signed 32-bit int in JavaScript -- writeUInt32LE
306
+ * then refuses the negative number rather than storing the bits. */
307
+ entry.writeUInt32LE((mode * 0x10000) >>> 0, 38);
308
+ entry.writeUInt32LE(offset, 42);
309
+ nameBytes.copy(entry, 46);
310
+ central.push(entry);
311
+
312
+ offset += local.length + compressed.length;
313
+ }
314
+
315
+ const directory = Buffer.concat(central);
316
+ const end = Buffer.alloc(22);
317
+ end.writeUInt32LE(0x06054b50, 0);
318
+ end.writeUInt16LE(contents.size, 8);
319
+ end.writeUInt16LE(contents.size, 10);
320
+ end.writeUInt32LE(directory.length, 12);
321
+ end.writeUInt32LE(offset, 16);
322
+ return Buffer.concat([...locals, directory, end]);
323
+ }
324
+
325
+ /** Minimal central-directory reader, for `inspect`. */
326
+ function readZip(bytes) {
327
+ const entries = [];
328
+ let end = bytes.length - 22;
329
+ while (end >= 0 && bytes.readUInt32LE(end) !== 0x06054b50) end -= 1;
330
+ if (end < 0) throw new Error("not a zip archive");
331
+ const count = bytes.readUInt16LE(end + 10);
332
+ let at = bytes.readUInt32LE(end + 16);
333
+
334
+ for (let index = 0; index < count; index += 1) {
335
+ if (bytes.readUInt32LE(at) !== 0x02014b50) throw new Error("damaged central directory");
336
+ const method = bytes.readUInt16LE(at + 10);
337
+ const compressedSize = bytes.readUInt32LE(at + 20);
338
+ const nameLength = bytes.readUInt16LE(at + 28);
339
+ const extraLength = bytes.readUInt16LE(at + 30);
340
+ const commentLength = bytes.readUInt16LE(at + 32);
341
+ const localOffset = bytes.readUInt32LE(at + 42);
342
+ const name = bytes.toString("utf8", at + 46, at + 46 + nameLength);
343
+
344
+ const localNameLength = bytes.readUInt16LE(localOffset + 26);
345
+ const localExtraLength = bytes.readUInt16LE(localOffset + 28);
346
+ const start = localOffset + 30 + localNameLength + localExtraLength;
347
+ const raw = bytes.subarray(start, start + compressedSize);
348
+ entries.push({ name, data: method === 0 ? raw : inflateRawSync(raw) });
349
+ at += 46 + nameLength + extraLength + commentLength;
350
+ }
351
+ return entries;
352
+ }
353
+
354
+ function crc32(buffer) {
355
+ let crc = ~0;
356
+ for (const byte of buffer) {
357
+ crc ^= byte;
358
+ for (let bit = 0; bit < 8; bit += 1) {
359
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
360
+ }
361
+ }
362
+ return (~crc) >>> 0;
363
+ }
package/dist/agent.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as Container } from './container-C3cgcnXZ.cjs';
1
+ import { C as Container } from './container-CPwyHoUy.cjs';
2
2
  import './contracts-B6SHFjma.cjs';
3
3
 
4
4
  /**
package/dist/agent.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as Container } from './container-WEYRUg6Q.js';
1
+ import { C as Container } from './container-CFieZcbe.js';
2
2
  import './contracts-B6SHFjma.js';
3
3
 
4
4
  /**
@@ -1455,6 +1455,15 @@ interface ContainerOptions {
1455
1455
  /** CPU count reported by `nproc` and `/proc/cpuinfo`. Default 4. */
1456
1456
  cpus?: number;
1457
1457
  network?: NetworkOptions;
1458
+ /**
1459
+ * Applications to install before the container is handed back, by name or
1460
+ * `name@version` — the same specifiers `pm install` takes.
1461
+ *
1462
+ * These are programs, not language packages: `pip` and `npm` install those.
1463
+ * Installing needs the registry to be reachable, so a container with
1464
+ * outbound access turned off has to carry them another way.
1465
+ */
1466
+ modules?: string[];
1458
1467
  timezone?: string;
1459
1468
  /** Default wall-clock limit for `exec`. Omit for no limit. */
1460
1469
  timeoutMs?: number;
@@ -1455,6 +1455,15 @@ interface ContainerOptions {
1455
1455
  /** CPU count reported by `nproc` and `/proc/cpuinfo`. Default 4. */
1456
1456
  cpus?: number;
1457
1457
  network?: NetworkOptions;
1458
+ /**
1459
+ * Applications to install before the container is handed back, by name or
1460
+ * `name@version` — the same specifiers `pm install` takes.
1461
+ *
1462
+ * These are programs, not language packages: `pip` and `npm` install those.
1463
+ * Installing needs the registry to be reachable, so a container with
1464
+ * outbound access turned off has to carry them another way.
1465
+ */
1466
+ modules?: string[];
1458
1467
  timezone?: string;
1459
1468
  /** Default wall-clock limit for `exec`. Omit for no limit. */
1460
1469
  timeoutMs?: number;