haechi 1.4.0 → 1.6.0

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.
@@ -30,9 +30,73 @@ const FORBIDDEN_KEYS = new Set([
30
30
  "scopes", "labels"
31
31
  ]);
32
32
 
33
- export function createJsonlAuditSink({ path, anchor = null }) {
33
+ // An audit STORE abstracts the exclusive "read-previous + persist" primitive so
34
+ // the SAME core-owned sha256 hash chain can sit on top of a file today and a
35
+ // shared store (e.g. Redis) in a future satellite. The contract is:
36
+ //
37
+ // async transaction(fn) — runs `fn` inside an EXCLUSIVE critical section that
38
+ // serializes concurrent appends. `fn` receives { readLastIntegrity, persist }
39
+ // where readLastIntegrity() -> the last record's auditIntegrity (or null) and
40
+ // persist(record) durably appends the built record. transaction() returns
41
+ // fn's return value.
42
+ // async ready() — OPTIONAL health/writability probe returning { ok, reason? };
43
+ // the sink falls back to { ok: true } when the store omits it.
44
+ //
45
+ // The store deliberately knows NOTHING about anchoring, sanitization, or the
46
+ // chain math — those stay core-owned in createAuditSink so a non-core store can
47
+ // never fork or weaken the chain.
48
+
49
+ // createFileAuditStore implements the store contract over the CURRENT JSONL
50
+ // mechanism: a `${path}.lock` exclusive section wrapping mkdir + the critical
51
+ // section, a tail-read for the previous integrity, and an appendFile persist.
52
+ // The on-disk bytes are identical to the pre-seam sink.
53
+ export function createFileAuditStore({ path }) {
34
54
  if (!path) {
35
- throw new Error("JSONL audit sink requires path");
55
+ throw new Error("file audit store requires path");
56
+ }
57
+
58
+ return {
59
+ async transaction(fn) {
60
+ await mkdir(dirname(path), { recursive: true });
61
+ return withFileLock(`${path}.lock`, () => fn({
62
+ readLastIntegrity: () => readLastIntegrity(path),
63
+ persist: (record) => appendFile(path, `${JSON.stringify(record)}\n`, "utf8")
64
+ }));
65
+ },
66
+
67
+ // WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
68
+ // A security gateway that cannot append to its audit log is NOT ready
69
+ // (fail-closed), so this confirms the audit directory exists and is writable
70
+ // WITHOUT writing an event (no audit-chain side effect). It returns the bare
71
+ // boolean and an enum reason — never a path value or any payload/PII.
72
+ async ready() {
73
+ try {
74
+ const dir = dirname(path);
75
+ await mkdir(dir, { recursive: true });
76
+ await access(dir, fsConstants.W_OK);
77
+ // If the audit file already exists, confirm it is writable too.
78
+ try {
79
+ await access(path, fsConstants.W_OK);
80
+ } catch (error) {
81
+ if (error.code !== "ENOENT") {
82
+ return { ok: false, reason: "audit_file_not_writable" };
83
+ }
84
+ }
85
+ return { ok: true };
86
+ } catch {
87
+ return { ok: false, reason: "audit_dir_not_writable" };
88
+ }
89
+ }
90
+ };
91
+ }
92
+
93
+ // createAuditSink holds the SECURITY-CRITICAL, core-owned logic: writeQueue
94
+ // serialization, sanitizeAudit, the sha256 chain build, the anchor stream, and
95
+ // the capabilities object. The store only supplies the exclusive
96
+ // read-previous + persist primitive; anchor config never leaks into it.
97
+ export function createAuditSink({ store, anchor = null }) {
98
+ if (!store || typeof store.transaction !== "function") {
99
+ throw new Error("audit sink requires a store with a transaction(fn) method");
36
100
  }
37
101
  const anchorMode = anchor?.mode ?? "none";
38
102
  const anchorPath = anchor?.path ?? null;
@@ -81,44 +145,41 @@ export function createJsonlAuditSink({ path, anchor = null }) {
81
145
  integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
82
146
  },
83
147
  async record(event) {
84
- const write = writeQueue.then(async () => {
85
- await mkdir(dirname(path), { recursive: true });
86
- await withFileLock(`${path}.lock`, async () => {
87
- const record = await buildIntegrityRecord(path, sanitizeAudit(event));
88
- await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
89
- await writeAnchor(record);
90
- });
91
- });
148
+ // The writeQueue serializes record() calls on this sink, and the store's
149
+ // transaction() adds the exclusive critical section; together they keep
150
+ // the chain strictly sequential and never forked under concurrency.
151
+ const write = writeQueue.then(() => store.transaction(async ({ readLastIntegrity, persist }) => {
152
+ const record = buildIntegrityRecord(await readLastIntegrity(), sanitizeAudit(event));
153
+ await persist(record);
154
+ await writeAnchor(record);
155
+ return record;
156
+ }));
92
157
  writeQueue = write.catch(() => {});
93
158
  await write;
94
159
  },
95
160
 
96
- // WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
97
- // A security gateway that cannot append to its audit log is NOT ready
98
- // (fail-closed), so this confirms the audit directory exists and is writable
99
- // WITHOUT writing an event (no audit-chain side effect). It returns the bare
100
- // boolean and an enum reason — never a path value or any payload/PII.
101
161
  async ready() {
102
- try {
103
- const dir = dirname(path);
104
- await mkdir(dir, { recursive: true });
105
- await access(dir, fsConstants.W_OK);
106
- // If the audit file already exists, confirm it is writable too.
107
- try {
108
- await access(path, fsConstants.W_OK);
109
- } catch (error) {
110
- if (error.code !== "ENOENT") {
111
- return { ok: false, reason: "audit_file_not_writable" };
112
- }
113
- }
114
- return { ok: true };
115
- } catch {
116
- return { ok: false, reason: "audit_dir_not_writable" };
162
+ // Delegate to the store's writability probe; a store that omits it is
163
+ // treated as ready (the chain math has no readiness side effect of its own).
164
+ if (typeof store.ready === "function") {
165
+ return store.ready();
117
166
  }
167
+ return { ok: true };
118
168
  }
119
169
  };
120
170
  }
121
171
 
172
+ // Thin back-compat wrapper: the original file-backed sink is now createAuditSink
173
+ // over createFileAuditStore. Its returned shape (id, version, capabilities,
174
+ // record, ready) and on-disk bytes are unchanged, so existing call sites
175
+ // (runtime.mjs injection, tests) keep working untouched.
176
+ export function createJsonlAuditSink({ path, anchor = null }) {
177
+ if (!path) {
178
+ throw new Error("JSONL audit sink requires path");
179
+ }
180
+ return createAuditSink({ store: createFileAuditStore({ path }), anchor });
181
+ }
182
+
122
183
  export async function readAuditSummary(path) {
123
184
  const summary = {
124
185
  events: 0,
@@ -272,8 +333,13 @@ async function readAnchors(anchorPath) {
272
333
  return { bySequence, lastSequence };
273
334
  }
274
335
 
275
- async function buildIntegrityRecord(path, event) {
276
- const previous = await readLastIntegrity(path);
336
+ // PURE chain math: given the previous record's auditIntegrity (or null) and a
337
+ // sanitized event, deterministically computes the next chained record. No fs,
338
+ // no IO — the store supplies `previousIntegrity` (via its read-previous
339
+ // primitive) so the SAME computation backs a file or a shared store. Exported
340
+ // for store/satellite tests.
341
+ export function buildIntegrityRecord(previousIntegrity, event) {
342
+ const previous = previousIntegrity ?? null;
277
343
  const sequence = previous ? previous.sequence + 1 : 1;
278
344
  const unsigned = {
279
345
  ...event,
@@ -8,7 +8,7 @@ import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundl
8
8
  import { PluginLoadError, signPluginManifest, validatePluginManifestFile, verifySignedPlugin } from "../../plugin/index.mjs";
9
9
  import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
10
10
  import { addToken, listTokens, revokeToken } from "../../auth/index.mjs";
11
- import { createLocalCryptoProvider } from "../../crypto/index.mjs";
11
+ import { createLocalCryptoProvider, readNonceBudget } from "../../crypto/index.mjs";
12
12
  import { spawn } from "node:child_process";
13
13
  import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
14
14
 
@@ -227,6 +227,34 @@ async function statusCommand(argv) {
227
227
  warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
228
228
  }
229
229
 
230
+ // Surface the local AES-GCM nonce budget so an operator can rotate BEFORE the
231
+ // fail-closed limit (the in-band signal; the runtime also warns at 50% on
232
+ // stderr). Only the local provider has a software budget — an external
233
+ // cryptoProvider (KMS) owns its own nonce discipline.
234
+ keys.nonceBudget = null;
235
+ if (keys.exists && config.keys.provider === "local") {
236
+ try {
237
+ const budget = await readNonceBudget(config.keys.keyFile);
238
+ const usedPercent = Math.round(budget.usedFraction * 1000) / 10;
239
+ keys.nonceBudget = {
240
+ kid: budget.kid,
241
+ used: budget.used,
242
+ limit: budget.limit,
243
+ remaining: budget.remaining,
244
+ usedPercent,
245
+ exhausted: budget.exhausted
246
+ };
247
+ if (budget.exhausted) {
248
+ warnings.push(`crypto key ${budget.kid} has EXHAUSTED its safe encryption budget (${budget.limit}); encryption is failing closed — rotate now with 'haechi init --force'`);
249
+ } else if (budget.used >= budget.warnThreshold) {
250
+ warnings.push(`crypto key ${budget.kid} has used ${usedPercent}% of its safe encryption budget; plan a rotation ('haechi init --force')`);
251
+ }
252
+ } catch {
253
+ // A malformed/active-key-less file is already surfaced by init/encrypt;
254
+ // do not double-warn from the status read.
255
+ }
256
+ }
257
+
230
258
  const anchorEnabled = config.audit.anchor.mode === "file";
231
259
  const audit = {
232
260
  path: config.audit.path,
@@ -983,7 +1011,7 @@ const COMMAND_HELP = {
983
1011
  status: {
984
1012
  usage: "haechi status [--config haechi.config.json]",
985
1013
  summary: "Show what is and is not protected under the current config.",
986
- detail: "Prints effective policy mode, response/streaming protection, target, token vault governance, key file permissions, audit chain status, and a consolidated warnings list."
1014
+ detail: "Prints effective policy mode, response/streaming protection, target, token vault governance, key file permissions, the local key's AES-GCM nonce budget (used %), audit chain status, and a consolidated warnings list."
987
1015
  },
988
1016
  proxy: {
989
1017
  usage: `haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]`,
@@ -4,6 +4,22 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
 
5
5
  const ALG = "AES-256-GCM";
6
6
 
7
+ // Random 96-bit GCM IVs are only safe up to a bounded number of invocations per
8
+ // key: by the birthday bound the IV-collision probability stays negligible only
9
+ // below ~2^32 encryptions under ONE key (NIST SP 800-38D §8.3 caps random-IV
10
+ // invocations at 2^32). A nonce collision under AES-GCM is catastrophic (it
11
+ // leaks the XOR of the two plaintexts and enables forgery), so the local
12
+ // provider FAILS CLOSED at the limit rather than risk reuse — the operator must
13
+ // rotate (`haechi init --force`). The count is persisted per-kid in the key file
14
+ // (see reserveNonceWindow) so it survives restarts; rotation resets it.
15
+ const MAX_ENCRYPTIONS_PER_KEY = 2 ** 32;
16
+ const NONCE_WARN_THRESHOLD = 2 ** 31; // warn once at 50% of the budget
17
+ // Invocations are reserved a window at a time and the window is persisted BEFORE
18
+ // it is consumed, so a crash/restart can only OVER-count (skip an unused tail of
19
+ // a window) — never under-count into reuse. A large window keeps the per-encrypt
20
+ // overhead at ~one key-file write per million encryptions.
21
+ const NONCE_RESERVE_WINDOW = 2 ** 20;
22
+
7
23
  // Single source of truth for parsing + validating an on-disk local key file.
8
24
  // Both the provider's loadKeys() and initLocalKeyFile() (existing-file path)
9
25
  // go through here so the 32-byte key invariant is enforced once. Throws a
@@ -50,6 +66,84 @@ export function createLocalCryptoProvider({ keyFile }) {
50
66
  return cachedKeys;
51
67
  }
52
68
 
69
+ // Per-process view of the active key's reserved nonce window:
70
+ // { kid, base, granted, used } where base is the key file's `usage` at the
71
+ // window start and (base + used) is the next invocation index. null until the
72
+ // first encrypt reserves a window.
73
+ let reservation = null;
74
+ let nonceWarned = false;
75
+ // Set if the key file cannot be written (e.g. read-only mount): the budget
76
+ // then degrades to PER-PROCESS enforcement and counts forward in memory.
77
+ let persistDisabled = false;
78
+
79
+ // Reserve the next window of invocations for `activeKid` by advancing the
80
+ // persisted `usage` BEFORE consuming it (fail-closed at the per-key limit).
81
+ // Read-modify-write the key file in place, preserving every other field. The
82
+ // local provider is the single-writer reference provider; concurrent writers
83
+ // sharing one key file are out of scope (production custody uses a KMS
84
+ // satellite) — a documented residual, not silent reuse, since reuse needs an
85
+ // actual IV collision and over-counting only wastes budget. If the key file is
86
+ // not writable, fall back to per-process counting (warned once) rather than
87
+ // breaking encryption on a hardened read-only mount.
88
+ async function reserveNonceWindow(activeKid) {
89
+ let current;
90
+ let raw = null;
91
+ let entry = null;
92
+ if (persistDisabled && reservation && reservation.kid === activeKid) {
93
+ // No persistence: continue counting forward from the last window in memory.
94
+ current = reservation.base + reservation.granted;
95
+ } else {
96
+ raw = JSON.parse(await readFile(keyFile, "utf8"));
97
+ entry = raw.keys?.find((k) => k.kid === activeKid);
98
+ if (!entry) {
99
+ throw new Error(`Active key ${activeKid} not found while reserving nonce budget`);
100
+ }
101
+ current = entry.usage ?? 0;
102
+ }
103
+ if (current >= MAX_ENCRYPTIONS_PER_KEY) {
104
+ throw new Error(
105
+ `local AES-256-GCM key ${activeKid} reached its safe encryption limit (${MAX_ENCRYPTIONS_PER_KEY}); rotate the key with 'haechi init --force' before encrypting more`
106
+ );
107
+ }
108
+ const granted = Math.min(NONCE_RESERVE_WINDOW, MAX_ENCRYPTIONS_PER_KEY - current);
109
+ if (!persistDisabled && entry) {
110
+ try {
111
+ entry.usage = current + granted;
112
+ await writeFile(keyFile, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
113
+ } catch (error) {
114
+ persistDisabled = true;
115
+ process.emitWarning(
116
+ `local AES-256-GCM nonce budget for key ${activeKid} cannot be persisted (${error?.code ?? error?.message}); enforcing the PER-PROCESS limit only — cross-restart protection is OFF, so rotate keys on a schedule`,
117
+ { code: "HAECHI_NONCE_BUDGET_NOPERSIST" }
118
+ );
119
+ }
120
+ }
121
+ reservation = { kid: activeKid, base: current, granted, used: 0 };
122
+ }
123
+
124
+ // Account one GCM encryption against the active key's nonce budget, reserving
125
+ // a fresh window when the current one is exhausted. Returns nothing; throws
126
+ // fail-closed at the limit. MUST be called before generating the IV.
127
+ async function consumeNonceBudget(activeKid) {
128
+ if (!reservation || reservation.kid !== activeKid || reservation.used >= reservation.granted) {
129
+ await reserveNonceWindow(activeKid);
130
+ }
131
+ const index = reservation.base + reservation.used; // 0-based invocation count
132
+ if (index >= MAX_ENCRYPTIONS_PER_KEY) {
133
+ throw new Error(
134
+ `local AES-256-GCM key ${activeKid} reached its safe encryption limit (${MAX_ENCRYPTIONS_PER_KEY}); rotate the key with 'haechi init --force' before encrypting more`
135
+ );
136
+ }
137
+ reservation.used += 1;
138
+ if (!nonceWarned && index >= NONCE_WARN_THRESHOLD) {
139
+ nonceWarned = true;
140
+ process.emitWarning(
141
+ `local AES-256-GCM key ${activeKid} has used ${index} of ${MAX_ENCRYPTIONS_PER_KEY} safe encryptions; plan a key rotation ('haechi init --force')`,
142
+ { code: "HAECHI_NONCE_BUDGET" }
143
+ );
144
+ }
145
+ }
146
+
53
147
  return {
54
148
  id: "haechi.crypto.local-aes-gcm",
55
149
  version: "0.1.0",
@@ -59,6 +153,9 @@ export function createLocalCryptoProvider({ keyFile }) {
59
153
  },
60
154
  async encrypt({ plaintext, aad }) {
61
155
  const { active: { kid, key } } = await loadKeys();
156
+ // Fail closed at the per-key random-IV invocation limit BEFORE choosing an
157
+ // IV, so we never generate a nonce past the safe budget (NIST SP 800-38D).
158
+ await consumeNonceBudget(kid);
62
159
  const iv = randomBytes(12);
63
160
  const cipher = createCipheriv("aes-256-gcm", key, iv);
64
161
  const aadBytes = Buffer.from(canonicalize(aad), "utf8");
@@ -268,6 +365,28 @@ export async function assertCryptoProviderConformance(provider, { requireHmac =
268
365
  return { ok: true };
269
366
  }
270
367
 
368
+ // Read the active key's nonce-budget status for operator visibility (e.g.
369
+ // `haechi status`). `used` reflects the PERSISTED reservation (advanced a window
370
+ // at a time), so it is a slight SAFE over-estimate of actual encryptions — never
371
+ // an under-estimate. Throws if the file has no usable active key.
372
+ export async function readNonceBudget(keyFile) {
373
+ const raw = JSON.parse(await readFile(keyFile, "utf8"));
374
+ const activeEntry = raw.keys?.find((key) => key.status === "active") ?? raw.keys?.[0];
375
+ if (!activeEntry) {
376
+ throw new Error("No active key found while reading nonce budget");
377
+ }
378
+ const used = activeEntry.usage ?? 0;
379
+ return {
380
+ kid: activeEntry.kid,
381
+ used,
382
+ limit: MAX_ENCRYPTIONS_PER_KEY,
383
+ remaining: Math.max(0, MAX_ENCRYPTIONS_PER_KEY - used),
384
+ usedFraction: used / MAX_ENCRYPTIONS_PER_KEY,
385
+ warnThreshold: NONCE_WARN_THRESHOLD,
386
+ exhausted: used >= MAX_ENCRYPTIONS_PER_KEY
387
+ };
388
+ }
389
+
271
390
  export function canonicalize(value) {
272
391
  if (Array.isArray(value)) {
273
392
  return `[${value.map((item) => canonicalize(item)).join(",")}]`;