haechi 0.3.2 → 0.4.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.
@@ -54,6 +54,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
54
54
  ? await haechi.protectJson(json, {
55
55
  ...routeContext,
56
56
  operation: `request:${routeContext.operation}`,
57
+ direction: "request",
57
58
  mode: config.policy.mode ?? config.mode
58
59
  })
59
60
  : { payload: json, blocked: false };
@@ -77,7 +78,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
77
78
  const forwarded = await maybeProtectResponse({
78
79
  upstreamResponse,
79
80
  routeContext,
80
- runtime
81
+ runtime,
82
+ issuedTokens: result.issuedTokens ?? []
81
83
  });
82
84
 
83
85
  response.writeHead(forwarded.status, forwarded.headers);
@@ -112,7 +114,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
112
114
  };
113
115
  }
114
116
 
115
- async function maybeProtectResponse({ upstreamResponse, routeContext, runtime }) {
117
+ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, issuedTokens = [] }) {
116
118
  const headers = Object.fromEntries(upstreamResponse.headers.entries());
117
119
 
118
120
  if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
@@ -203,6 +205,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime })
203
205
  const result = await runtime.haechi.protectJson(json, {
204
206
  ...routeContext,
205
207
  operation: `response:${routeContext.operation}`,
208
+ direction: "response",
206
209
  mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
207
210
  });
208
211
 
@@ -218,13 +221,47 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime })
218
221
  };
219
222
  }
220
223
 
224
+ let responsePayload = result.payload;
225
+
226
+ // Request-scoped token round-trip: restore ONLY tokens issued/reused while
227
+ // protecting this request, so the model sees tokens but the caller sees
228
+ // plaintext. Explicit opt-in; runs after response protection, so an opt-in
229
+ // here intentionally overrides response-direction transforms for values the
230
+ // caller already sent.
231
+ if (runtime.config.tokenVault.detokenizeResponses
232
+ && issuedTokens.length > 0
233
+ && typeof runtime.tokenVault?.detokenize === "function") {
234
+ const values = await runtime.tokenVault.detokenize({ tokens: issuedTokens });
235
+ if (values.size > 0) {
236
+ responsePayload = restoreTokens(responsePayload, values);
237
+ }
238
+ }
239
+
221
240
  return {
222
241
  status: upstreamResponse.status,
223
242
  headers: transformedJsonHeaders(headers),
224
- body: Buffer.from(`${JSON.stringify(result.payload)}\n`)
243
+ body: Buffer.from(`${JSON.stringify(responsePayload)}\n`)
225
244
  };
226
245
  }
227
246
 
247
+ function restoreTokens(value, tokenValues) {
248
+ if (typeof value === "string") {
249
+ let output = value;
250
+ for (const [token, plaintext] of tokenValues) {
251
+ output = output.split(`[TOKEN:${token}]`).join(plaintext);
252
+ }
253
+ return output;
254
+ }
255
+ if (Array.isArray(value)) {
256
+ return value.map((item) => restoreTokens(item, tokenValues));
257
+ }
258
+ if (value && typeof value === "object") {
259
+ return Object.fromEntries(Object.entries(value)
260
+ .map(([key, item]) => [restoreTokens(key, tokenValues), restoreTokens(item, tokenValues)]));
261
+ }
262
+ return value;
263
+ }
264
+
228
265
  async function forward({ upstream, request, body, timeoutMs = null }) {
229
266
  const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
230
267
  try {
@@ -462,6 +499,7 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
462
499
  protocol: routeContext?.protocol ?? "proxy",
463
500
  operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
464
501
  mode: runtime.config.policy.mode ?? runtime.config.mode,
502
+ identity: null,
465
503
  enforced,
466
504
  blocked,
467
505
  decision,
@@ -3,13 +3,33 @@ import { dirname } from "node:path";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
 
6
- export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "disabled", retentionDays = 30, auditSink = null }) {
6
+ const DETERMINISTIC_DOMAIN = "haechi:token-vault:deterministic:v1";
7
+
8
+ export function createLocalTokenVault({
9
+ path,
10
+ cryptoProvider,
11
+ revealPolicy = "disabled",
12
+ retentionDays = 30,
13
+ auditSink = null,
14
+ deterministic = false,
15
+ deterministicTypes = null
16
+ }) {
7
17
  if (!path) {
8
18
  throw new Error("Local token vault requires path");
9
19
  }
10
20
  if (!cryptoProvider) {
11
21
  throw new Error("Local token vault requires cryptoProvider");
12
22
  }
23
+ if (deterministic && typeof cryptoProvider.hmac !== "function") {
24
+ throw new Error("Deterministic tokenization requires a cryptoProvider with hmac()");
25
+ }
26
+
27
+ function isDeterministicType(type) {
28
+ if (!deterministic) {
29
+ return false;
30
+ }
31
+ return !deterministicTypes || deterministicTypes.includes(type);
32
+ }
13
33
 
14
34
  let mutationQueue = Promise.resolve();
15
35
  async function enqueueMutation(operation) {
@@ -32,6 +52,7 @@ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "di
32
52
  timestamp: new Date().toISOString(),
33
53
  protocol: "token-vault",
34
54
  operation,
55
+ identity: null,
35
56
  mode: "n/a",
36
57
  enforced: true,
37
58
  blocked: decision.endsWith("_denied"),
@@ -62,10 +83,26 @@ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "di
62
83
  revealPolicy
63
84
  },
64
85
  async tokenize({ plaintext, type, context = {}, metadata = {} }) {
86
+ // Deterministic tokens are derived outside the mutation lock (HMAC reads
87
+ // only the key file); the same (type, value) always maps to one token.
88
+ const token = isDeterministicType(type)
89
+ ? `tok_${type}_${(await cryptoProvider.hmac({
90
+ data: `${type}:${plaintext}`,
91
+ domain: DETERMINISTIC_DOMAIN
92
+ })).slice(0, 32)}`
93
+ : `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
94
+
65
95
  return enqueueMutation(async () => {
66
96
  const vault = await readVault(path);
67
97
  pruneExpiredTokens(vault);
68
- const token = `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
98
+
99
+ const existing = vault.tokens[token];
100
+ if (existing) {
101
+ existing.expiresAt = addDays(new Date(), retentionDays).toISOString();
102
+ await writeVault(path, vault);
103
+ return { token, type, reused: true };
104
+ }
105
+
69
106
  const createdAt = new Date();
70
107
  const aad = {
71
108
  purpose: "token-vault",
@@ -127,6 +164,37 @@ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "di
127
164
  throw error;
128
165
  }
129
166
  },
167
+ // Request-scoped response restoration. Deliberately NOT gated by
168
+ // revealPolicy: that governs manual/CLI reveal, while detokenize is only
169
+ // reachable through the proxy's explicit detokenizeResponses opt-in and is
170
+ // limited to the caller-supplied token set. Audited by count, no plaintext.
171
+ async detokenize({ tokens }) {
172
+ const vault = await readVault(path);
173
+ const values = new Map();
174
+ let skipped = 0;
175
+
176
+ for (const token of tokens) {
177
+ const record = vault.tokens[token];
178
+ if (!record || (record.expiresAt && Date.parse(record.expiresAt) < Date.now())) {
179
+ skipped += 1;
180
+ continue;
181
+ }
182
+ try {
183
+ values.set(token, await cryptoProvider.decrypt({ envelope: record.envelope, aad: record.aad }));
184
+ } catch {
185
+ skipped += 1;
186
+ }
187
+ }
188
+
189
+ await recordVaultEvent({
190
+ operation: "token-vault:detokenize",
191
+ decision: "detokenize",
192
+ count: values.size,
193
+ reason: skipped > 0 ? `${skipped} tokens not restored` : null
194
+ });
195
+
196
+ return values;
197
+ },
130
198
  async purge({ token }) {
131
199
  return enqueueMutation(async () => {
132
200
  const vault = await readVault(path);