haechi 1.7.0 → 1.8.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.
@@ -4,6 +4,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
 
5
5
  const ALG = "AES-256-GCM";
6
6
  export const CRYPTO_AAD_ENCODING_V2 = "nfkc-json-v2";
7
+ // v3 binds the envelope's freshness fields (createdAt/expiresAt) into the AAD
8
+ // itself, so tampering them (e.g. deleting/extending expiresAt on a stored
9
+ // envelope) fails the GCM auth tag instead of silently bypassing expiry. The
10
+ // v2 AAD encoding is already published and MUST NOT change (legacy decrypt
11
+ // stays on canonicalizeCryptoAad(aad) exactly as before) — v3 is additive.
12
+ export const CRYPTO_AAD_ENCODING_V3 = "nfkc-json-v3";
7
13
 
8
14
  // Random 96-bit GCM IVs are only safe up to a bounded number of invocations per
9
15
  // key: by the birthday bound the IV-collision probability stays negligible only
@@ -159,23 +165,33 @@ export function createLocalCryptoProvider({ keyFile }) {
159
165
  await consumeNonceBudget(kid);
160
166
  const iv = randomBytes(12);
161
167
  const cipher = createCipheriv("aes-256-gcm", key, iv);
162
- const aadBytes = Buffer.from(canonicalizeCryptoAad(aad), "utf8");
168
+ // Compute freshness FIRST so the AAD binds them (v3): a tampered
169
+ // createdAt/expiresAt on a stored envelope now fails the GCM auth tag
170
+ // instead of silently bypassing expiry.
171
+ const createdAt = new Date().toISOString();
172
+ const normalizedExpiresAt = expiresAt !== null && expiresAt !== undefined
173
+ ? normalizeEnvelopeExpiry(expiresAt)
174
+ : null;
175
+ const aadBytes = Buffer.from(
176
+ canonicalizeCryptoAad({ aad, createdAt, expiresAt: normalizedExpiresAt }),
177
+ "utf8"
178
+ );
163
179
  cipher.setAAD(aadBytes);
164
180
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
165
181
  const tag = cipher.getAuthTag();
166
182
  const envelope = {
167
- v: 2,
183
+ v: 3,
168
184
  alg: ALG,
169
185
  kid,
170
186
  iv: iv.toString("base64url"),
171
187
  ct: ciphertext.toString("base64url"),
172
188
  tag: tag.toString("base64url"),
173
189
  aadHash: sha256(aadBytes),
174
- aadEncoding: CRYPTO_AAD_ENCODING_V2,
175
- createdAt: new Date().toISOString()
190
+ aadEncoding: CRYPTO_AAD_ENCODING_V3,
191
+ createdAt
176
192
  };
177
- if (expiresAt !== null && expiresAt !== undefined) {
178
- envelope.expiresAt = normalizeEnvelopeExpiry(expiresAt);
193
+ if (normalizedExpiresAt !== null) {
194
+ envelope.expiresAt = normalizedExpiresAt;
179
195
  }
180
196
  return envelope;
181
197
  },
@@ -429,10 +445,26 @@ export function canonicalizeCryptoAad(value) {
429
445
  }
430
446
 
431
447
  function canonicalizeAadForEnvelope(envelope, aad) {
432
- if (envelope.aadEncoding && envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V2) {
448
+ if (
449
+ envelope.aadEncoding &&
450
+ envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V2 &&
451
+ envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V3
452
+ ) {
433
453
  throw new Error(`Unsupported crypto AAD encoding: ${envelope.aadEncoding}`);
434
454
  }
455
+ if (envelope.aadEncoding === CRYPTO_AAD_ENCODING_V3 || envelope.v === 3) {
456
+ // v3: freshness fields are bound into the AAD, so a tampered
457
+ // createdAt/expiresAt no longer canonicalizes to the same bytes the
458
+ // encryptor authenticated — the GCM tag check below fails closed.
459
+ return canonicalizeCryptoAad({
460
+ aad,
461
+ createdAt: envelope.createdAt ?? null,
462
+ expiresAt: envelope.expiresAt ?? null
463
+ });
464
+ }
435
465
  if (envelope.aadEncoding === CRYPTO_AAD_ENCODING_V2 || envelope.v === 2) {
466
+ // v2 (legacy, UNCHANGED): freshness fields are NOT bound into the AAD, so
467
+ // they remain advisory/unauthenticated for v2 envelopes by design.
436
468
  return canonicalizeCryptoAad(aad);
437
469
  }
438
470
  return canonicalize(aad);
@@ -27,7 +27,8 @@ const COUNTERS = {
27
27
  haechi_upstream_error_total: "Upstream requests that failed (non-timeout).",
28
28
  haechi_response_unprotected_total: "Responses forwarded without protection (size/encoding/parse).",
29
29
  haechi_internal_error_total: "Unexpected internal proxy errors.",
30
- haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503)."
30
+ haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503).",
31
+ haechi_usage_record_failed_total: "Usage recorder failures after a proxied request completed."
31
32
  };
32
33
 
33
34
  const HISTOGRAMS = {
@@ -6,6 +6,7 @@ import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { once } from "node:events";
8
8
  import { inspectResponseStream } from "../stream-filter/index.mjs";
9
+ import { recordUsageEvent } from "../usage/index.mjs";
9
10
 
10
11
  export const DEFAULT_PROXY_PORT = 11016;
11
12
 
@@ -142,6 +143,16 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
142
143
  const correlationId = randomUUID();
143
144
  const startedAt = process.hrtime.bigint();
144
145
  let routeId = "unknown";
146
+ let routeContext = null;
147
+ let usageIdentity = null;
148
+ let usageProfile = null;
149
+ let usageRequestBytes = null;
150
+ let usageResponseBytes = null;
151
+ let usageModel = null;
152
+ let usageDecision = "unknown";
153
+ let usageBlocked = false;
154
+ let usageUpstreamReached = false;
155
+ let usageUpstreamStatusCode = null;
145
156
 
146
157
  // Observability routes are exempt from the in-flight ceiling and are NOT
147
158
  // counted toward it: liveness/readiness/metrics must answer under saturation.
@@ -209,7 +220,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
209
220
  }
210
221
 
211
222
  assertRelativeProxyTarget(request.url);
212
- const routeContext = protocolAdapter.classifyRequest(request);
223
+ routeContext = protocolAdapter.classifyRequest(request);
213
224
  routeId = routeContext?.routeId ?? "unknown";
214
225
  const mode = config.policy.mode ?? config.mode;
215
226
 
@@ -217,6 +228,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
217
228
  // the body, so a denied/throttled request cannot stream a large body.
218
229
  const gate = await authorizeRequest({ runtime, request, routeContext, rateLimiter, metrics, correlationId });
219
230
  if (gate.denied) {
231
+ usageDecision = gate.denied.error?.replace(/^haechi_/, "") ?? "denied";
232
+ usageBlocked = true;
220
233
  writeJson(response, gate.denied.status, {
221
234
  error: gate.denied.error,
222
235
  message: gate.denied.message
@@ -224,13 +237,17 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
224
237
  return;
225
238
  }
226
239
  const { identity, profile, policyEngine, modelAllowlist } = gate;
240
+ usageIdentity = identity;
241
+ usageProfile = profile;
227
242
  const authContext = { identity, profile, policyEngine, correlationId };
228
243
 
229
244
  const body = await readBody(request, {
230
245
  maxBytes: config.limits.maxRequestBytes,
231
246
  response
232
247
  });
248
+ usageRequestBytes = Buffer.byteLength(body, "utf8");
233
249
  const json = parseJsonBody(body);
250
+ usageModel = typeof json?.model === "string" ? json.model : null;
234
251
 
235
252
  // Model allowlist runs after body read (the model field is in the body).
236
253
  if (modelAllowlist && typeof json?.model === "string" && !modelAllowlist.includes(json.model)) {
@@ -243,6 +260,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
243
260
  });
244
261
  countDecision(metrics, { routeContext, mode, decision: "model_not_allowed" });
245
262
  metrics.increment("haechi_blocks_total");
263
+ usageDecision = "model_not_allowed";
264
+ usageBlocked = true;
246
265
  writeJson(response, 403, {
247
266
  error: "haechi_model_not_allowed",
248
267
  message: `Model not allowed: ${json.model}`
@@ -252,7 +271,11 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
252
271
 
253
272
  if (isStreamingRequest(json, routeContext)) {
254
273
  if (config.streaming.requestMode === "inspect") {
255
- await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics, forwardPolicy });
274
+ const streamOutcome = await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics, forwardPolicy });
275
+ usageDecision = streamOutcome.decision;
276
+ usageBlocked = streamOutcome.blocked;
277
+ usageUpstreamReached = streamOutcome.upstreamReached;
278
+ usageUpstreamStatusCode = streamOutcome.upstreamStatusCode;
256
279
  return;
257
280
  }
258
281
 
@@ -282,6 +305,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
282
305
  forwardPolicy,
283
306
  abortController: streamAbort
284
307
  });
308
+ usageUpstreamReached = true;
309
+ usageUpstreamStatusCode = upstreamResponse.status;
285
310
  // P1-CR-003 — sanitize response headers (strip the upstream's
286
311
  // content-encoding/content-length/transfer/hop-by-hop) on this path
287
312
  // too: Node fetch() auto-decompressed the body, so the original
@@ -299,10 +324,13 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
299
324
  metrics,
300
325
  correlationId
301
326
  });
327
+ usageDecision = "streaming_request_pass_through";
302
328
  return;
303
329
  }
304
330
 
305
331
  countDecision(metrics, { routeContext, mode, decision: "streaming_blocked" });
332
+ usageDecision = "streaming_blocked";
333
+ usageBlocked = true;
306
334
  writeJson(response, 501, {
307
335
  error: "haechi_streaming_unsupported",
308
336
  message: "Streaming requests are blocked unless streaming.requestMode is set to pass-through or inspect"
@@ -323,6 +351,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
323
351
  if (result.blocked) {
324
352
  countDecision(metrics, { routeContext, mode, decision: "blocked" });
325
353
  metrics.increment("haechi_blocks_total");
354
+ usageDecision = "blocked";
355
+ usageBlocked = true;
326
356
  writeJson(response, 403, {
327
357
  error: "haechi_policy_block",
328
358
  summary: result.summary,
@@ -339,6 +369,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
339
369
  metrics,
340
370
  forwardPolicy
341
371
  });
372
+ usageUpstreamReached = true;
373
+ usageUpstreamStatusCode = upstreamResponse.status;
342
374
 
343
375
  const forwarded = await maybeProtectResponse({
344
376
  upstreamResponse,
@@ -354,10 +386,15 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
354
386
  mode,
355
387
  decision: forwarded.decision ?? "forwarded"
356
388
  });
389
+ usageDecision = forwarded.decision ?? "forwarded";
390
+ usageBlocked = forwarded.status >= 400 || forwarded.decision?.includes("blocked") || forwarded.decision === "response_blocked";
391
+ usageResponseBytes = Buffer.isBuffer(forwarded.body) ? forwarded.body.byteLength : null;
357
392
  response.writeHead(forwarded.status, forwarded.headers);
358
393
  response.end(forwarded.body);
359
394
  } catch (error) {
360
395
  const expected = typeof error?.statusCode === "number";
396
+ usageDecision = error?.errorCode ?? "proxy_error";
397
+ usageBlocked = true;
361
398
  if (!expected) {
362
399
  // Carry the error NAME/class + correlationId only — NEVER the payload,
363
400
  // headers, token, or any PII.
@@ -380,6 +417,25 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
380
417
  }, extraHeaders);
381
418
  } finally {
382
419
  const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9;
420
+ await maybeRecordUsage({
421
+ runtime,
422
+ request,
423
+ routeContext,
424
+ identity: usageIdentity,
425
+ profile: usageProfile,
426
+ correlationId,
427
+ requestBytes: usageRequestBytes,
428
+ responseBytes: usageResponseBytes,
429
+ model: usageModel,
430
+ decision: usageDecision,
431
+ blocked: usageBlocked,
432
+ upstreamReached: usageUpstreamReached,
433
+ upstreamStatusCode: usageUpstreamStatusCode,
434
+ statusCode: response.statusCode,
435
+ durationMs: Math.round(elapsedSeconds * 1000),
436
+ exemptRoute,
437
+ metrics
438
+ });
383
439
  // route label is a bounded route id (or "unknown") — never an identity/value.
384
440
  metrics.observe("haechi_request_duration_seconds", elapsedSeconds, { route: routeId });
385
441
  if (counted) {
@@ -545,6 +601,56 @@ function countDecision(metrics, { routeContext, mode, decision }) {
545
601
  });
546
602
  }
547
603
 
604
+ async function maybeRecordUsage({
605
+ runtime,
606
+ request,
607
+ routeContext,
608
+ identity,
609
+ profile,
610
+ correlationId,
611
+ requestBytes,
612
+ responseBytes,
613
+ model,
614
+ decision,
615
+ blocked,
616
+ upstreamReached,
617
+ upstreamStatusCode,
618
+ statusCode,
619
+ durationMs,
620
+ exemptRoute,
621
+ metrics
622
+ }) {
623
+ if (exemptRoute || !runtime.config.usage?.enabled) {
624
+ return;
625
+ }
626
+ try {
627
+ await recordUsageEvent({
628
+ usageRecorder: runtime.usageRecorder,
629
+ auditSink: runtime.auditSink,
630
+ audit: runtime.config.usage.audit,
631
+ event: {
632
+ correlationId,
633
+ routeContext,
634
+ identity,
635
+ profile,
636
+ method: request.method,
637
+ path: request.url,
638
+ requestBytes,
639
+ responseBytes,
640
+ statusCode,
641
+ outcomeDecision: decision,
642
+ blocked,
643
+ upstreamReached,
644
+ upstreamStatusCode,
645
+ model,
646
+ durationMs
647
+ }
648
+ });
649
+ } catch {
650
+ metrics?.increment("haechi_usage_record_failed_total");
651
+ }
652
+ }
653
+
548
654
  // Backward-compat fallback for a hand-built runtime object without metrics: a
549
655
  // no-op collector with the same increment/observe/render contract.
550
656
  function noopMetrics() {
@@ -685,7 +791,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
685
791
  error: "haechi_streaming_uninspectable_route",
686
792
  message: `streaming.requestMode is "inspect" but route ${routeContext.routeId} has no known streaming format`
687
793
  });
688
- return;
794
+ return { decision: "streaming_uninspectable_route", blocked: true, upstreamReached: false, upstreamStatusCode: null };
689
795
  }
690
796
 
691
797
  // The request body is ordinary JSON even when the response streams, so it is
@@ -708,7 +814,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
708
814
  summary: requestResult.summary,
709
815
  auditId: requestResult.auditEvent.id
710
816
  });
711
- return;
817
+ return { decision: "blocked", blocked: true, upstreamReached: false, upstreamStatusCode: null };
712
818
  }
713
819
 
714
820
  const upstreamResponse = await forward({
@@ -749,6 +855,12 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
749
855
  metrics?.increment("haechi_blocks_total");
750
856
  }
751
857
  response.end();
858
+ return {
859
+ decision: blocked ? "stream_blocked" : "stream_inspected",
860
+ blocked,
861
+ upstreamReached: true,
862
+ upstreamStatusCode: upstreamResponse.status
863
+ };
752
864
  }
753
865
 
754
866
  // P1-CR-003 — the SINGLE centralized response-header sanitizer used on EVERY
@@ -0,0 +1,177 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ export const USAGE_EVENT_SCHEMA_VERSION = "1";
4
+
5
+ const SAFE_TOKEN = /^[A-Za-z0-9_.:/-]{1,64}$/;
6
+
7
+ export function createNoopUsageRecorder() {
8
+ return Object.freeze({
9
+ id: "haechi.usage.noop",
10
+ async record() {
11
+ return { recorded: false };
12
+ }
13
+ });
14
+ }
15
+
16
+ export function createNoopQuotaProvider() {
17
+ return Object.freeze({
18
+ id: "haechi.quota.noop",
19
+ async check() {
20
+ return { allowed: true, reason: "not_configured" };
21
+ }
22
+ });
23
+ }
24
+
25
+ export function coerceUsageRecorder(provider) {
26
+ if (typeof provider === "function") {
27
+ return { id: "haechi.usage.function", record: provider };
28
+ }
29
+ if (provider && typeof provider.record === "function") {
30
+ return provider;
31
+ }
32
+ throw new Error("usageRecorder must be a function or an object with record(event)");
33
+ }
34
+
35
+ export function coerceQuotaProvider(provider) {
36
+ if (typeof provider === "function") {
37
+ return { id: "haechi.quota.function", check: provider };
38
+ }
39
+ if (provider && typeof provider.check === "function") {
40
+ return provider;
41
+ }
42
+ throw new Error("quotaProvider must be a function or an object with check(context)");
43
+ }
44
+
45
+ export function normalizeQuotaDecision(result) {
46
+ if (result === true) {
47
+ return { allowed: true, reason: "allowed" };
48
+ }
49
+ if (result === false) {
50
+ return { allowed: false, reason: "denied" };
51
+ }
52
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
53
+ throw new Error("quotaProvider.check() must return boolean or { allowed, reason? }");
54
+ }
55
+ if (typeof result.allowed !== "boolean") {
56
+ throw new Error("quotaProvider.check() result.allowed must be boolean");
57
+ }
58
+ return {
59
+ allowed: result.allowed,
60
+ reason: safeToken(result.reason, result.allowed ? "allowed" : "denied"),
61
+ retryAfterSeconds: safePositiveInteger(result.retryAfterSeconds)
62
+ };
63
+ }
64
+
65
+ export function projectUsageIdentity(identity) {
66
+ if (!identity) {
67
+ return null;
68
+ }
69
+ return {
70
+ id: safeToken(identity.id, "unknown"),
71
+ type: safeToken(identity.type, "unknown"),
72
+ subjectHash: safeHash(identity.subjectHash),
73
+ issuerHash: safeHash(identity.issuerHash),
74
+ provider: safeToken(identity.provider, "unknown")
75
+ };
76
+ }
77
+
78
+ export function buildUsageEvent({
79
+ correlationId = null,
80
+ timestamp = new Date().toISOString(),
81
+ routeContext = null,
82
+ identity = null,
83
+ profile = null,
84
+ method = null,
85
+ path = null,
86
+ requestBytes = null,
87
+ responseBytes = null,
88
+ statusCode = null,
89
+ outcomeDecision = "unknown",
90
+ blocked = false,
91
+ upstreamReached = false,
92
+ upstreamStatusCode = null,
93
+ model = null,
94
+ durationMs = null
95
+ } = {}) {
96
+ const routeId = safeToken(routeContext?.routeId, "unknown");
97
+ const outcome = safeToken(outcomeDecision, "unknown");
98
+ const modelText = typeof model === "string" && model.length > 0 ? model : null;
99
+ return {
100
+ schemaVersion: USAGE_EVENT_SCHEMA_VERSION,
101
+ id: randomUUID(),
102
+ eventType: "usage_recorded",
103
+ correlationId: typeof correlationId === "string" ? correlationId : null,
104
+ timestamp,
105
+ protocol: safeToken(routeContext?.protocol, "proxy"),
106
+ operation: `usage:${safeToken(routeContext?.protocol, "proxy")}:${routeId}`,
107
+ identity: projectUsageIdentity(identity),
108
+ profile: profile === null || profile === undefined ? null : safeToken(profile, "unknown"),
109
+ decision: "usage_recorded",
110
+ reason: "request_completed",
111
+ routeId,
112
+ method: safeToken(method, "UNKNOWN"),
113
+ pathHash: typeof path === "string" ? shortHash(path) : null,
114
+ outcome: {
115
+ decision: outcome,
116
+ blocked: Boolean(blocked),
117
+ statusCode: safeStatusCode(statusCode)
118
+ },
119
+ upstream: {
120
+ reached: Boolean(upstreamReached),
121
+ statusCode: safeStatusCode(upstreamStatusCode)
122
+ },
123
+ request: {
124
+ bytes: safeNonNegativeInteger(requestBytes),
125
+ modelHash: modelText ? shortHash(modelText) : null,
126
+ modelPresent: Boolean(modelText)
127
+ },
128
+ response: {
129
+ bytes: safeNonNegativeInteger(responseBytes)
130
+ },
131
+ durationMs: safeNonNegativeInteger(durationMs),
132
+ summary: {
133
+ detectionCount: 0,
134
+ byType: {},
135
+ byAction: {
136
+ usage_recorded: 1
137
+ }
138
+ }
139
+ };
140
+ }
141
+
142
+ export async function recordUsageEvent({ usageRecorder, auditSink = null, audit = true, event }) {
143
+ const normalized = buildUsageEvent(event);
144
+ await usageRecorder.record(normalized);
145
+ if (audit && typeof auditSink?.record === "function") {
146
+ await auditSink.record(normalized);
147
+ }
148
+ return normalized;
149
+ }
150
+
151
+ function safeToken(value, fallback) {
152
+ if (typeof value !== "string") {
153
+ return fallback;
154
+ }
155
+ const trimmed = value.trim();
156
+ return SAFE_TOKEN.test(trimmed) ? trimmed : fallback;
157
+ }
158
+
159
+ function safeHash(value) {
160
+ return typeof value === "string" && /^[a-f0-9]{16,128}$/i.test(value) ? value : "unknown";
161
+ }
162
+
163
+ function safeStatusCode(value) {
164
+ return Number.isInteger(value) && value >= 100 && value <= 599 ? value : null;
165
+ }
166
+
167
+ function safePositiveInteger(value) {
168
+ return Number.isInteger(value) && value > 0 ? value : null;
169
+ }
170
+
171
+ function safeNonNegativeInteger(value) {
172
+ return Number.isInteger(value) && value >= 0 ? value : null;
173
+ }
174
+
175
+ function shortHash(value) {
176
+ return createHash("sha256").update(String(value)).digest("hex").slice(0, 12);
177
+ }