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.
- package/README.ko.md +6 -0
- package/README.md +5 -1
- package/docs/current/api-stability.ko.md +3 -3
- package/docs/current/api-stability.md +3 -3
- package/docs/current/config-version.ko.md +1 -1
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +1 -1
- package/docs/current/configuration.md +1 -1
- package/docs/current/operations-runbook.ko.md +32 -2
- package/docs/current/operations-runbook.md +35 -2
- package/docs/current/plugin-signing-and-trust.ko.md +1 -1
- package/docs/current/plugin-signing-and-trust.md +1 -1
- package/docs/current/release-process.ko.md +12 -1
- package/docs/current/release-process.md +12 -1
- package/docs/current/risk-register-release-gate.ko.md +9 -7
- package/docs/current/risk-register-release-gate.md +9 -7
- package/docs/current/shared-responsibility.ko.md +1 -1
- package/docs/current/shared-responsibility.md +1 -1
- package/docs/current/threat-model.ko.md +6 -5
- package/docs/current/threat-model.md +6 -5
- package/package.json +2 -1
- package/packages/audit/index.mjs +98 -32
- package/packages/cli/bin/haechi.mjs +30 -2
- package/packages/crypto/index.mjs +119 -0
- package/packages/token-vault/index.mjs +167 -56
|
@@ -11,8 +11,89 @@ const AUDIT_ID_DOMAIN = "haechi:token-vault:audit-id:v1";
|
|
|
11
11
|
// this shape is treated as a misused raw value and never written verbatim.
|
|
12
12
|
const VAULT_TOKEN_SHAPE = /^tok_[a-z0-9_]+_[a-f0-9]{16,}$/;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// A token STORE abstracts the token-record map + the exclusive mutation section
|
|
15
|
+
// so the SAME core-owned tokenization can sit on a whole-file vault today and a
|
|
16
|
+
// shared store (e.g. Redis) in a future satellite — the current whole-file
|
|
17
|
+
// rewrite is not safe with multiple writers, so a shared store needs its own
|
|
18
|
+
// exclusive critical section. The contract is:
|
|
19
|
+
//
|
|
20
|
+
// async mutate(fn) — runs `fn` inside an EXCLUSIVE critical section that
|
|
21
|
+
// serializes concurrent mutations. `fn` receives a MUTABLE view
|
|
22
|
+
// { get(token), set(token, record), delete(token), entries() } over the
|
|
23
|
+
// token-record map, and the store persists the changes ATOMICALLY when `fn`
|
|
24
|
+
// resolves. mutate() returns `fn`'s return value. This is the
|
|
25
|
+
// multi-writer-safety primitive.
|
|
26
|
+
// async read(fn) — read-only access. `fn` receives { get(token), entries() }
|
|
27
|
+
// over a FRESH snapshot (no lock, matching how reveal/detokenize/export read
|
|
28
|
+
// today). read() returns `fn`'s value.
|
|
29
|
+
//
|
|
30
|
+
// The store deliberately knows NOTHING about crypto, reveal governance,
|
|
31
|
+
// retention, or audit — those stay core-owned in createTokenVault so a non-core
|
|
32
|
+
// store can never fork or weaken them. Prune-on-mutation is also core-owned: the
|
|
33
|
+
// core deletes expired entries from the view before each operation, so the file
|
|
34
|
+
// store persists the pruning on the trailing writeVault (no store cooperation
|
|
35
|
+
// needed) and the in-memory store sees the same deletions.
|
|
36
|
+
|
|
37
|
+
// createFileTokenStore implements the store contract over the CURRENT vault
|
|
38
|
+
// mechanism: a `${path}.lock` exclusive section wrapping mkdir + readVault +
|
|
39
|
+
// writeVault, with the view operating on vault.tokens in memory. The on-disk
|
|
40
|
+
// vault JSON format (version/createdAt/tokens, 2-space, trailing newline,
|
|
41
|
+
// temp+rename, 0600) stays byte-identical to the pre-seam vault.
|
|
42
|
+
export function createFileTokenStore({ path }) {
|
|
43
|
+
if (!path) {
|
|
44
|
+
throw new Error("file token store requires path");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async mutate(fn) {
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
51
|
+
const vault = await readVault(path);
|
|
52
|
+
const result = await fn(mutableView(vault.tokens));
|
|
53
|
+
await writeVault(path, vault);
|
|
54
|
+
return result;
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async read(fn) {
|
|
59
|
+
const vault = await readVault(path);
|
|
60
|
+
return fn(readView(vault.tokens));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A mutable view over a token-record map (the file store backs this with
|
|
66
|
+
// vault.tokens; the in-memory store with a Map). get/set/delete operate on the
|
|
67
|
+
// live map so the store persists whatever the mutation left behind.
|
|
68
|
+
function mutableView(tokens) {
|
|
69
|
+
return {
|
|
70
|
+
get: (token) => tokens[token],
|
|
71
|
+
set: (token, record) => {
|
|
72
|
+
tokens[token] = record;
|
|
73
|
+
},
|
|
74
|
+
delete: (token) => {
|
|
75
|
+
delete tokens[token];
|
|
76
|
+
},
|
|
77
|
+
entries: () => Object.entries(tokens)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readView(tokens) {
|
|
82
|
+
return {
|
|
83
|
+
get: (token) => tokens[token],
|
|
84
|
+
entries: () => Object.entries(tokens)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// createTokenVault holds ALL the SECURITY-CRITICAL, core-owned logic:
|
|
89
|
+
// mutationQueue serialization (cross-call), deterministic-vs-random token id
|
|
90
|
+
// derivation, encrypt/decrypt, reveal governance (revealPolicy gate +
|
|
91
|
+
// reasonCodes + safeAuditToken + recordVaultEvent), retention
|
|
92
|
+
// (expiresAt/prune-on-mutation), detokenize, purge/purgeExpired,
|
|
93
|
+
// exportMetadata, and capabilities. The store only supplies the exclusive
|
|
94
|
+
// mutate/read primitive over the token-record map.
|
|
95
|
+
export function createTokenVault({
|
|
96
|
+
store,
|
|
16
97
|
cryptoProvider,
|
|
17
98
|
revealPolicy = "disabled",
|
|
18
99
|
retentionDays = 30,
|
|
@@ -20,8 +101,8 @@ export function createLocalTokenVault({
|
|
|
20
101
|
deterministic = false,
|
|
21
102
|
deterministicTypes = null
|
|
22
103
|
}) {
|
|
23
|
-
if (!
|
|
24
|
-
throw new Error("
|
|
104
|
+
if (!store || typeof store.mutate !== "function" || typeof store.read !== "function") {
|
|
105
|
+
throw new Error("token vault requires a store with mutate(fn) and read(fn) methods");
|
|
25
106
|
}
|
|
26
107
|
if (!cryptoProvider) {
|
|
27
108
|
throw new Error("Local token vault requires cryptoProvider");
|
|
@@ -37,16 +118,31 @@ export function createLocalTokenVault({
|
|
|
37
118
|
return !deterministicTypes || deterministicTypes.includes(type);
|
|
38
119
|
}
|
|
39
120
|
|
|
121
|
+
// The mutationQueue (cross-call serialization) stays in core, wrapping
|
|
122
|
+
// store.mutate. Together with the store's own exclusive critical section this
|
|
123
|
+
// keeps concurrent tokenize/purge from corrupting or losing tokens.
|
|
40
124
|
let mutationQueue = Promise.resolve();
|
|
41
125
|
async function enqueueMutation(operation) {
|
|
42
|
-
const mutation = mutationQueue.then(
|
|
43
|
-
await mkdir(dirname(path), { recursive: true });
|
|
44
|
-
return withFileLock(`${path}.lock`, operation);
|
|
45
|
-
});
|
|
126
|
+
const mutation = mutationQueue.then(() => store.mutate(operation));
|
|
46
127
|
mutationQueue = mutation.catch(() => {});
|
|
47
128
|
return mutation;
|
|
48
129
|
}
|
|
49
130
|
|
|
131
|
+
// Prune expired entries from the mutable view before each operation. For the
|
|
132
|
+
// file store this deletes from the in-memory map so they are gone after the
|
|
133
|
+
// trailing writeVault; for any store the deletions are persisted by mutate().
|
|
134
|
+
// Returns the number pruned (purgeExpired counts on this).
|
|
135
|
+
function pruneExpiredView(view, now = Date.now()) {
|
|
136
|
+
let purged = 0;
|
|
137
|
+
for (const [token, record] of view.entries()) {
|
|
138
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < now) {
|
|
139
|
+
view.delete(token);
|
|
140
|
+
purged += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return purged;
|
|
144
|
+
}
|
|
145
|
+
|
|
50
146
|
// The audit `token` field must never carry a raw secret. A legitimate token
|
|
51
147
|
// id is a non-sensitive opaque `tok_<type>_<hexhash>` — recorded verbatim for
|
|
52
148
|
// correlation. A caller who misuses the API and passes a raw value where a
|
|
@@ -122,14 +218,13 @@ export function createLocalTokenVault({
|
|
|
122
218
|
})).slice(0, 32)}`
|
|
123
219
|
: `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
|
|
124
220
|
|
|
125
|
-
return enqueueMutation(async () => {
|
|
126
|
-
|
|
127
|
-
pruneExpiredTokens(vault);
|
|
221
|
+
return enqueueMutation(async (view) => {
|
|
222
|
+
pruneExpiredView(view);
|
|
128
223
|
|
|
129
|
-
const existing =
|
|
224
|
+
const existing = view.get(token);
|
|
130
225
|
if (existing) {
|
|
131
226
|
existing.expiresAt = addDays(new Date(), retentionDays).toISOString();
|
|
132
|
-
|
|
227
|
+
view.set(token, existing);
|
|
133
228
|
return { token, type, reused: true };
|
|
134
229
|
}
|
|
135
230
|
|
|
@@ -140,15 +235,14 @@ export function createLocalTokenVault({
|
|
|
140
235
|
type,
|
|
141
236
|
context
|
|
142
237
|
};
|
|
143
|
-
|
|
238
|
+
view.set(token, {
|
|
144
239
|
type,
|
|
145
240
|
createdAt: createdAt.toISOString(),
|
|
146
241
|
expiresAt: addDays(createdAt, retentionDays).toISOString(),
|
|
147
242
|
metadata: sanitizeMetadata(metadata),
|
|
148
243
|
envelope: await cryptoProvider.encrypt({ plaintext, aad }),
|
|
149
244
|
aad
|
|
150
|
-
};
|
|
151
|
-
await writeVault(path, vault);
|
|
245
|
+
});
|
|
152
246
|
return { token, type };
|
|
153
247
|
});
|
|
154
248
|
},
|
|
@@ -166,8 +260,7 @@ export function createLocalTokenVault({
|
|
|
166
260
|
// token); the message itself never interpolates the token argument.
|
|
167
261
|
let reasonCode = "reveal_error";
|
|
168
262
|
try {
|
|
169
|
-
const
|
|
170
|
-
const record = vault.tokens[token];
|
|
263
|
+
const record = await store.read((view) => view.get(token));
|
|
171
264
|
if (!record) {
|
|
172
265
|
reasonCode = "unknown_token";
|
|
173
266
|
throw new Error("Unknown token");
|
|
@@ -210,12 +303,19 @@ export function createLocalTokenVault({
|
|
|
210
303
|
// reachable through the proxy's explicit detokenizeResponses opt-in and is
|
|
211
304
|
// limited to the caller-supplied token set. Audited by count, no plaintext.
|
|
212
305
|
async detokenize({ tokens }) {
|
|
213
|
-
const
|
|
306
|
+
const records = await store.read((view) => {
|
|
307
|
+
const found = new Map();
|
|
308
|
+
for (const token of tokens) {
|
|
309
|
+
found.set(token, view.get(token));
|
|
310
|
+
}
|
|
311
|
+
return found;
|
|
312
|
+
});
|
|
313
|
+
|
|
214
314
|
const values = new Map();
|
|
215
315
|
let skipped = 0;
|
|
216
316
|
|
|
217
317
|
for (const token of tokens) {
|
|
218
|
-
const record =
|
|
318
|
+
const record = records.get(token);
|
|
219
319
|
if (!record || (record.expiresAt && Date.parse(record.expiresAt) < Date.now())) {
|
|
220
320
|
skipped += 1;
|
|
221
321
|
continue;
|
|
@@ -237,36 +337,30 @@ export function createLocalTokenVault({
|
|
|
237
337
|
return values;
|
|
238
338
|
},
|
|
239
339
|
async purge({ token }) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
251
|
-
return { token, purged: existed, purgedAt: new Date().toISOString() };
|
|
340
|
+
const existed = await enqueueMutation(async (view) => {
|
|
341
|
+
pruneExpiredView(view);
|
|
342
|
+
const present = Boolean(view.get(token));
|
|
343
|
+
view.delete(token);
|
|
344
|
+
return present;
|
|
345
|
+
});
|
|
346
|
+
await recordVaultEvent({
|
|
347
|
+
operation: "token-vault:purge",
|
|
348
|
+
decision: "purge",
|
|
349
|
+
token
|
|
252
350
|
});
|
|
351
|
+
return { token, purged: existed, purgedAt: new Date().toISOString() };
|
|
253
352
|
},
|
|
254
353
|
async purgeExpired() {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
operation: "token-vault:purge-expired",
|
|
261
|
-
decision: "purge_expired",
|
|
262
|
-
count: purged
|
|
263
|
-
});
|
|
264
|
-
return { purged, purgedAt: new Date().toISOString() };
|
|
354
|
+
const purged = await enqueueMutation(async (view) => pruneExpiredView(view));
|
|
355
|
+
await recordVaultEvent({
|
|
356
|
+
operation: "token-vault:purge-expired",
|
|
357
|
+
decision: "purge_expired",
|
|
358
|
+
count: purged
|
|
265
359
|
});
|
|
360
|
+
return { purged, purgedAt: new Date().toISOString() };
|
|
266
361
|
},
|
|
267
362
|
async exportMetadata({ type = null } = {}) {
|
|
268
|
-
|
|
269
|
-
return Object.entries(vault.tokens)
|
|
363
|
+
return store.read((view) => view.entries()
|
|
270
364
|
.filter(([, record]) => !type || record.type === type)
|
|
271
365
|
.map(([token, record]) => ({
|
|
272
366
|
token,
|
|
@@ -274,11 +368,39 @@ export function createLocalTokenVault({
|
|
|
274
368
|
createdAt: record.createdAt,
|
|
275
369
|
expiresAt: record.expiresAt,
|
|
276
370
|
metadata: sanitizeMetadata(record.metadata ?? {})
|
|
277
|
-
}));
|
|
371
|
+
})));
|
|
278
372
|
}
|
|
279
373
|
};
|
|
280
374
|
}
|
|
281
375
|
|
|
376
|
+
// Thin back-compat wrapper: the original file-backed vault is now
|
|
377
|
+
// createTokenVault over createFileTokenStore. Its returned shape (id, version,
|
|
378
|
+
// capabilities, tokenize, reveal, detokenize, purge, purgeExpired,
|
|
379
|
+
// exportMetadata) and on-disk bytes are unchanged, so existing call sites
|
|
380
|
+
// (runtime.mjs injection, tests) keep working untouched.
|
|
381
|
+
export function createLocalTokenVault({
|
|
382
|
+
path,
|
|
383
|
+
cryptoProvider,
|
|
384
|
+
revealPolicy = "disabled",
|
|
385
|
+
retentionDays = 30,
|
|
386
|
+
auditSink = null,
|
|
387
|
+
deterministic = false,
|
|
388
|
+
deterministicTypes = null
|
|
389
|
+
}) {
|
|
390
|
+
if (!path) {
|
|
391
|
+
throw new Error("Local token vault requires path");
|
|
392
|
+
}
|
|
393
|
+
return createTokenVault({
|
|
394
|
+
store: createFileTokenStore({ path }),
|
|
395
|
+
cryptoProvider,
|
|
396
|
+
revealPolicy,
|
|
397
|
+
retentionDays,
|
|
398
|
+
auditSink,
|
|
399
|
+
deterministic,
|
|
400
|
+
deterministicTypes
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
282
404
|
export async function readVault(path) {
|
|
283
405
|
try {
|
|
284
406
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -301,17 +423,6 @@ async function writeVault(path, vault) {
|
|
|
301
423
|
await rename(tempPath, path);
|
|
302
424
|
}
|
|
303
425
|
|
|
304
|
-
function pruneExpiredTokens(vault, now = Date.now()) {
|
|
305
|
-
let purged = 0;
|
|
306
|
-
for (const [token, record] of Object.entries(vault.tokens)) {
|
|
307
|
-
if (record.expiresAt && Date.parse(record.expiresAt) < now) {
|
|
308
|
-
delete vault.tokens[token];
|
|
309
|
-
purged += 1;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return purged;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
426
|
function sanitizeMetadata(metadata) {
|
|
316
427
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !["value", "plaintext", "payload"].includes(key)));
|
|
317
428
|
}
|