haechi 1.3.0 → 1.3.2

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.
@@ -4,6 +4,37 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
 
5
5
  const ALG = "AES-256-GCM";
6
6
 
7
+ // Single source of truth for parsing + validating an on-disk local key file.
8
+ // Both the provider's loadKeys() and initLocalKeyFile() (existing-file path)
9
+ // go through here so the 32-byte key invariant is enforced once. Throws a
10
+ // specific error per defect so a corrupted-but-present file is caught at init
11
+ // time instead of failing later during encrypt/decrypt/token/bundle.
12
+ //
13
+ // requireActive: init demands an explicit status:"active" key; the provider
14
+ // keeps its historical fallback to keys[0] when none is marked active.
15
+ async function loadKeyFile(keyFile, { requireActive = false } = {}) {
16
+ const raw = JSON.parse(await readFile(keyFile, "utf8"));
17
+ if (!raw.keys?.length) {
18
+ throw new Error(`No keys found in ${keyFile}`);
19
+ }
20
+ const byKid = new Map();
21
+ for (const entry of raw.keys) {
22
+ const key = Buffer.from(entry.k, "base64url");
23
+ if (key.length !== 32) {
24
+ throw new Error("AES-256-GCM local key must be 32 bytes");
25
+ }
26
+ byKid.set(entry.kid, { kid: entry.kid, key });
27
+ }
28
+ const activeEntry = raw.keys.find((key) => key.status === "active") ?? (requireActive ? null : raw.keys[0]);
29
+ if (!activeEntry) {
30
+ throw new Error("No active key found in local key file");
31
+ }
32
+ return {
33
+ active: byKid.get(activeEntry.kid),
34
+ byKid
35
+ };
36
+ }
37
+
7
38
  export function createLocalCryptoProvider({ keyFile }) {
8
39
  if (!keyFile) {
9
40
  throw new Error("Local crypto provider requires keyFile");
@@ -15,23 +46,7 @@ export function createLocalCryptoProvider({ keyFile }) {
15
46
  if (cachedKeys) {
16
47
  return cachedKeys;
17
48
  }
18
- const raw = JSON.parse(await readFile(keyFile, "utf8"));
19
- if (!raw.keys?.length) {
20
- throw new Error(`No keys found in ${keyFile}`);
21
- }
22
- const byKid = new Map();
23
- for (const entry of raw.keys) {
24
- const key = Buffer.from(entry.k, "base64url");
25
- if (key.length !== 32) {
26
- throw new Error("AES-256-GCM local key must be 32 bytes");
27
- }
28
- byKid.set(entry.kid, { kid: entry.kid, key });
29
- }
30
- const activeEntry = raw.keys.find((key) => key.status === "active") ?? raw.keys[0];
31
- cachedKeys = {
32
- active: byKid.get(activeEntry.kid),
33
- byKid
34
- };
49
+ cachedKeys = await loadKeyFile(keyFile);
35
50
  return cachedKeys;
36
51
  }
37
52
 
@@ -102,15 +117,22 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
102
117
  await mkdir(dirname(keyFile), { recursive: true });
103
118
 
104
119
  let existing = null;
120
+ let fileExists = true;
105
121
  try {
106
122
  existing = JSON.parse(await readFile(keyFile, "utf8"));
107
- if (!force) {
108
- return { created: false, keyFile };
109
- }
110
123
  } catch (error) {
111
124
  if (error.code !== "ENOENT") {
112
125
  throw error;
113
126
  }
127
+ fileExists = false;
128
+ }
129
+
130
+ if (fileExists && !force) {
131
+ // A present key file must be usable, not merely present: validate the
132
+ // active key (base64url, 32 bytes) and every retired key before reporting
133
+ // success, so a corrupted file is rejected here rather than at first use.
134
+ await loadKeyFile(keyFile, { requireActive: true });
135
+ return { created: false, keyFile };
114
136
  }
115
137
 
116
138
  // Rotating with --force must not orphan existing envelopes/token vault
@@ -44,8 +44,19 @@ import {
44
44
  // The child flags. `--permission` enables the deny-by-default Node permission
45
45
  // model; we pass NO --allow-* grant, so fs/child-process/worker/addons/wasi/net
46
46
  // are all kernel-denied. `--disable-proto=delete` removes Object.prototype.__proto__.
47
+ // A `--max-old-space-size=<mb>` heap cap is appended PER-SPAWN (see spawnAndLoad):
48
+ // unlike the worker (resourceLimits OOMs a runaway), a process child has NO heap
49
+ // cap by default, so a hostile/buggy signed plugin could build a reply up to the
50
+ // child's default V8 heap. The cap bounds the child; the host-side reply-size bound
51
+ // (CR2-003) bounds the host regardless.
47
52
  const CHILD_FLAGS = Object.freeze(["--permission", "--disable-proto=delete"]);
48
53
 
54
+ // Default child heap cap (MB) when a process-runtime config does not supply
55
+ // resourceLimits.maxOldGenerationSizeMb. Non-breaking: the worker REQUIRES the
56
+ // knob, but the process runtime defaults rather than throwing so an isolation:
57
+ // process config without resourceLimits keeps working.
58
+ const DEFAULT_MAX_OLD_GEN_MB = 128;
59
+
49
60
  // A CONSTANT bootstrap harness, passed via `node -e`. It is identical for every
50
61
  // plugin (the plugin bytes arrive over IPC, NOT on the command line — so there is
51
62
  // no ARG_MAX limit and the harness never varies). It runs as CommonJS under -e and
@@ -155,6 +166,10 @@ function createProcessIsolatedAuthProviderHandle({
155
166
  timeoutMs,
156
167
  maxPendingCalls = 8,
157
168
  maxMessageBytes = 16384,
169
+ // Child V8 heap cap. Reuses the worker's resourceLimits.maxOldGenerationSizeMb
170
+ // knob (CR2-003). Optional for the process runtime: a config that omits it falls
171
+ // back to DEFAULT_MAX_OLD_GEN_MB rather than throwing (non-breaking).
172
+ resourceLimits = null,
158
173
  coreVersion = null,
159
174
  now = Date.now,
160
175
  allowedLabelKeys,
@@ -201,6 +216,21 @@ function createProcessIsolatedAuthProviderHandle({
201
216
  if (!Number.isInteger(maxMessageBytes) || maxMessageBytes < 1) {
202
217
  throw new Error("maxMessageBytes must be a positive integer");
203
218
  }
219
+ // Resolve the child heap cap (MB). Optional for the process runtime; if supplied
220
+ // it must be a positive-integer maxOldGenerationSizeMb (same shape as the worker),
221
+ // else default to DEFAULT_MAX_OLD_GEN_MB (non-breaking — never throws on absence).
222
+ let maxOldGenerationSizeMb = DEFAULT_MAX_OLD_GEN_MB;
223
+ if (resourceLimits !== null && resourceLimits !== undefined) {
224
+ if (typeof resourceLimits !== "object" || Array.isArray(resourceLimits)) {
225
+ throw new Error("createProcessIsolatedAuthProvider resourceLimits must be an object");
226
+ }
227
+ if (resourceLimits.maxOldGenerationSizeMb !== undefined) {
228
+ if (!Number.isInteger(resourceLimits.maxOldGenerationSizeMb) || resourceLimits.maxOldGenerationSizeMb <= 0) {
229
+ throw new Error("createProcessIsolatedAuthProvider resourceLimits.maxOldGenerationSizeMb must be a positive integer");
230
+ }
231
+ maxOldGenerationSizeMb = resourceLimits.maxOldGenerationSizeMb;
232
+ }
233
+ }
204
234
  // Fail-closed network containment. PR1 supports only the "require-permission"
205
235
  // mode; if this Node cannot enforce --allow-net, refuse to construct rather than
206
236
  // run a plugin whose network egress is uncontained.
@@ -273,7 +303,11 @@ function createProcessIsolatedAuthProviderHandle({
273
303
  // any failure kills the child and throws → fail closed. NOTE the plugin source
274
304
  // crosses over IPC (not the command line) so there is no ARG_MAX limit.
275
305
  async function spawnAndLoad({ entrySource, pluginId: pid }) {
276
- const c = spawn(execPath, [...CHILD_FLAGS, "-e", PROCESS_HARNESS], {
306
+ // Build the spawn args by spreading the frozen base flags + the per-spawn heap
307
+ // cap. `--max-old-space-size` composes with `--permission`/`--disable-proto=
308
+ // delete` and the data:-URL load (verified). The cap bounds a runaway child;
309
+ // the host-side reply-size bound bounds the host regardless of the child heap.
310
+ const c = spawn(execPath, [...CHILD_FLAGS, `--max-old-space-size=${maxOldGenerationSizeMb}`, "-e", PROCESS_HARNESS], {
277
311
  stdio: ["ignore", "ignore", "ignore", "ipc"],
278
312
  serialization: "json",
279
313
  env: scrubbedEnv(),
@@ -291,6 +325,27 @@ function createProcessIsolatedAuthProviderHandle({
291
325
  const failed = new Promise((_, reject) => { onFail = reject; });
292
326
 
293
327
  c.on("message", (raw) => {
328
+ // REPLY SIZE BOUND (CR2-003): bound host-side work BEFORE JSON.parse. Unlike
329
+ // the worker (resourceLimits OOMs a runaway), the child has only the
330
+ // --max-old-space-size cap, so it can still build a reply up to that heap and
331
+ // process.send it; a synchronous JSON.parse of a multi-MB string stalls the
332
+ // host event loop (the per-call timeout cannot fire mid-parse). The reply is a
333
+ // STRING (serialization:'json'); measure its byte length and, if it exceeds the
334
+ // SAME maxMessageBytes ceiling the outbound credential obeys, drop the frame as
335
+ // an oversized DENY WITHOUT parsing. The auth reply is the only attacker-sized
336
+ // frame (claims come from the plugin); the tiny ready/loaded/load-error control
337
+ // frames are always far under the ceiling, so the uniform bound never harms the
338
+ // handshake. Single-occupancy: settle the one live pending call as oversized.
339
+ const replyBytes = typeof raw === "string"
340
+ ? Buffer.byteLength(raw, "utf8")
341
+ : Buffer.byteLength(String(raw), "utf8");
342
+ if (replyBytes > maxMessageBytes) {
343
+ for (const [cid, settle] of pending) {
344
+ pending.delete(cid);
345
+ settle({ __oversized: true });
346
+ }
347
+ return;
348
+ }
294
349
  let parsed;
295
350
  try {
296
351
  parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));
@@ -150,6 +150,29 @@ function createSandboxedAuthProviderHandle({
150
150
  workerData: {}
151
151
  });
152
152
  w.on("message", (raw) => {
153
+ // REPLY SIZE BOUND (CR2-003): bound host-side work BEFORE JSON.parse. The
154
+ // worker has an implicit heap cap (resourceLimits), but enforce the same
155
+ // maxMessageBytes ceiling on the INBOUND plugin→host reply that the OUTBOUND
156
+ // host→plugin credential message obeys — a hostile/buggy plugin can build a
157
+ // multi-MB reply and a synchronous JSON.parse would stall the host event loop
158
+ // (the per-call timeout cannot fire mid-parse). The reply is a STRING posted
159
+ // via JSON.stringify; measure its byte length and, if oversized, settle the
160
+ // matched call as an oversized DENY (mirroring the credential deny) WITHOUT
161
+ // parsing. We must locate the pending settle WITHOUT parsing the cid, so an
162
+ // oversized reply settles the single live pending call (single-occupancy: at
163
+ // most one entry is ever live).
164
+ const replyBytes = typeof raw === "string"
165
+ ? Buffer.byteLength(raw, "utf8")
166
+ : Buffer.byteLength(String(raw), "utf8");
167
+ if (replyBytes > maxMessageBytes) {
168
+ // Single-occupancy: settle the one live pending call as oversized, never
169
+ // touching JSON.parse on the oversized payload.
170
+ for (const [cid, settle] of pending) {
171
+ pending.delete(cid);
172
+ settle({ __oversized: true });
173
+ }
174
+ return;
175
+ }
153
176
  let parsed;
154
177
  try {
155
178
  parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));