haechi 0.5.0 → 0.7.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 +42 -0
- package/README.md +42 -0
- package/docs/README.md +2 -0
- package/docs/current/api-stability.ko.md +6 -1
- package/docs/current/api-stability.md +6 -1
- package/docs/current/configuration.ko.md +25 -2
- package/docs/current/configuration.md +25 -2
- package/docs/current/release-0.6-implementation-scope.ko.md +151 -0
- package/docs/current/release-0.6-implementation-scope.md +151 -0
- package/docs/current/release-0.7-implementation-scope.ko.md +91 -0
- package/docs/current/release-0.7-implementation-scope.md +92 -0
- package/docs/current/release-process.ko.md +26 -3
- package/docs/current/release-process.md +26 -3
- package/docs/current/risk-register-release-gate.ko.md +3 -3
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/threat-model.ko.md +7 -2
- package/docs/current/threat-model.md +7 -2
- package/examples/crypto-kms-reference/README.md +47 -0
- package/examples/crypto-kms-reference/index.mjs +133 -0
- package/examples/crypto-kms-reference/package.json +19 -0
- package/haechi.config.example.json +16 -1
- package/package.json +5 -2
- package/packages/audit/index.mjs +99 -6
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +131 -14
- package/packages/cli/runtime.mjs +136 -8
- package/packages/core/index.mjs +18 -7
- package/packages/crypto/index.mjs +107 -0
- package/packages/policy/index.mjs +82 -0
- package/packages/proxy/index.mjs +134 -8
- package/scripts/release-checksums.mjs +100 -0
package/packages/cli/runtime.mjs
CHANGED
|
@@ -2,13 +2,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { createHaechi } from "../core/index.mjs";
|
|
4
4
|
import { createDefaultFilterEngine } from "../filter/index.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { createPolicyProfiles } from "../policy/index.mjs";
|
|
6
6
|
import { createLocalCryptoProvider, initLocalKeyFile } from "../crypto/index.mjs";
|
|
7
7
|
import { createJsonlAuditSink } from "../audit/index.mjs";
|
|
8
8
|
import { createLocalTokenVault } from "../token-vault/index.mjs";
|
|
9
9
|
import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
|
|
10
10
|
import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
|
|
11
11
|
import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
|
|
12
|
+
import { createBearerAuthProvider } from "../auth/index.mjs";
|
|
12
13
|
import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
|
|
13
14
|
|
|
14
15
|
export const DEFAULT_CONFIG_PATH = "haechi.config.json";
|
|
@@ -59,7 +60,12 @@ export function defaultConfig() {
|
|
|
59
60
|
},
|
|
60
61
|
audit: {
|
|
61
62
|
sink: "jsonl",
|
|
62
|
-
path: ".haechi/audit.jsonl"
|
|
63
|
+
path: ".haechi/audit.jsonl",
|
|
64
|
+
anchor: {
|
|
65
|
+
mode: "none",
|
|
66
|
+
path: ".haechi/audit.anchor.jsonl",
|
|
67
|
+
everyRecords: 1
|
|
68
|
+
}
|
|
63
69
|
},
|
|
64
70
|
tokenVault: {
|
|
65
71
|
provider: "local",
|
|
@@ -73,6 +79,11 @@ export function defaultConfig() {
|
|
|
73
79
|
privacy: {
|
|
74
80
|
profile: null
|
|
75
81
|
},
|
|
82
|
+
auth: {
|
|
83
|
+
provider: "none",
|
|
84
|
+
store: ".haechi/auth.json",
|
|
85
|
+
allowedLabelKeys: ["team", "env", "tier", "role"]
|
|
86
|
+
},
|
|
76
87
|
mcp: {
|
|
77
88
|
allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
|
|
78
89
|
protectParams: true,
|
|
@@ -111,7 +122,18 @@ export function createRuntime(config, providers = {}) {
|
|
|
111
122
|
const normalized = normalizeConfig(config);
|
|
112
123
|
const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
|
|
113
124
|
assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
|
|
114
|
-
|
|
125
|
+
// hmac is only required by features that use it (bearer auth, deterministic
|
|
126
|
+
// tokenization). An encrypt-only external provider is valid otherwise; fail
|
|
127
|
+
// closed at construction rather than deep in a request if a needing feature
|
|
128
|
+
// is configured without it.
|
|
129
|
+
if (typeof cryptoProvider.hmac !== "function"
|
|
130
|
+
&& (normalized.auth.provider === "bearer" || normalized.tokenVault.deterministic)) {
|
|
131
|
+
throw new Error("cryptoProvider must implement hmac() for bearer auth / deterministic tokenization");
|
|
132
|
+
}
|
|
133
|
+
const auditSink = providers.auditSink ?? createJsonlAuditSink({
|
|
134
|
+
path: normalized.audit.path,
|
|
135
|
+
anchor: normalized.audit.anchor
|
|
136
|
+
});
|
|
115
137
|
assertProvider("auditSink", auditSink, ["record"]);
|
|
116
138
|
const tokenVault = providers.tokenVault ?? createLocalTokenVault({
|
|
117
139
|
path: normalized.tokenVault.path,
|
|
@@ -135,19 +157,25 @@ export function createRuntime(config, providers = {}) {
|
|
|
135
157
|
...normalized.policy,
|
|
136
158
|
mode: normalized.policy.mode ?? normalized.mode
|
|
137
159
|
};
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
160
|
+
const policyProfiles = createPolicyProfiles(policySource, {
|
|
161
|
+
transform: (source) => normalized.privacy.profile
|
|
162
|
+
? applyPrivacyProfile(source, normalized.privacy.profile)
|
|
163
|
+
: source
|
|
164
|
+
});
|
|
141
165
|
|
|
142
166
|
const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
|
|
143
167
|
assertProvider("filterEngine", filterEngine, ["detect"]);
|
|
144
|
-
const policyEngine = providers.policyEngine ??
|
|
168
|
+
const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
|
|
145
169
|
assertProvider("policyEngine", policyEngine, ["decide"]);
|
|
146
170
|
|
|
171
|
+
const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
|
|
172
|
+
|
|
147
173
|
return {
|
|
148
174
|
config: normalized,
|
|
149
175
|
tokenVault,
|
|
150
176
|
auditSink,
|
|
177
|
+
authProvider,
|
|
178
|
+
policyProfiles,
|
|
151
179
|
protocolAdapter: createProtocolAdapter(normalized.target),
|
|
152
180
|
haechi: createHaechi({
|
|
153
181
|
mode: normalized.mode,
|
|
@@ -202,7 +230,11 @@ export function normalizeConfig(config) {
|
|
|
202
230
|
},
|
|
203
231
|
audit: {
|
|
204
232
|
...defaultConfig().audit,
|
|
205
|
-
...(config.audit ?? {})
|
|
233
|
+
...(config.audit ?? {}),
|
|
234
|
+
anchor: {
|
|
235
|
+
...defaultConfig().audit.anchor,
|
|
236
|
+
...(config.audit?.anchor ?? {})
|
|
237
|
+
}
|
|
206
238
|
},
|
|
207
239
|
tokenVault: {
|
|
208
240
|
...defaultConfig().tokenVault,
|
|
@@ -212,6 +244,11 @@ export function normalizeConfig(config) {
|
|
|
212
244
|
...defaultConfig().privacy,
|
|
213
245
|
...(config.privacy ?? {})
|
|
214
246
|
},
|
|
247
|
+
auth: {
|
|
248
|
+
...defaultConfig().auth,
|
|
249
|
+
...(config.auth ?? {}),
|
|
250
|
+
allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
|
|
251
|
+
},
|
|
215
252
|
mcp: {
|
|
216
253
|
...defaultConfig().mcp,
|
|
217
254
|
...(config.mcp ?? {}),
|
|
@@ -231,6 +268,16 @@ export function normalizeConfig(config) {
|
|
|
231
268
|
if (merged.audit.sink !== "jsonl") {
|
|
232
269
|
throw new Error("Current implementation only supports jsonl audit sink");
|
|
233
270
|
}
|
|
271
|
+
if (!["none", "file", "stdout"].includes(merged.audit.anchor.mode)) {
|
|
272
|
+
throw new Error(`Invalid audit.anchor.mode: ${merged.audit.anchor.mode}`);
|
|
273
|
+
}
|
|
274
|
+
if (merged.audit.anchor.mode === "file"
|
|
275
|
+
&& (typeof merged.audit.anchor.path !== "string" || !merged.audit.anchor.path.trim())) {
|
|
276
|
+
throw new Error("audit.anchor.mode 'file' requires audit.anchor.path");
|
|
277
|
+
}
|
|
278
|
+
if (!Number.isInteger(merged.audit.anchor.everyRecords) || merged.audit.anchor.everyRecords < 1) {
|
|
279
|
+
throw new Error("audit.anchor.everyRecords must be a positive integer");
|
|
280
|
+
}
|
|
234
281
|
if (merged.tokenVault.provider !== "local") {
|
|
235
282
|
throw new Error("0.2 only supports local token vault provider");
|
|
236
283
|
}
|
|
@@ -288,6 +335,17 @@ export function normalizeConfig(config) {
|
|
|
288
335
|
if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
|
|
289
336
|
throw new Error("limits.upstreamTimeoutMs must be a positive number");
|
|
290
337
|
}
|
|
338
|
+
validatePolicyExtras(merged.policy);
|
|
339
|
+
if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
|
|
340
|
+
throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
|
|
341
|
+
}
|
|
342
|
+
if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
|
|
343
|
+
throw new Error("auth.store must be a non-empty string");
|
|
344
|
+
}
|
|
345
|
+
if (!Array.isArray(merged.auth.allowedLabelKeys)
|
|
346
|
+
|| !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
|
|
347
|
+
throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
|
|
348
|
+
}
|
|
291
349
|
createProtocolAdapter(merged.target);
|
|
292
350
|
return merged;
|
|
293
351
|
}
|
|
@@ -296,6 +354,76 @@ export function isValidPort(port) {
|
|
|
296
354
|
return Number.isInteger(port) && port >= 0 && port <= 65535;
|
|
297
355
|
}
|
|
298
356
|
|
|
357
|
+
function validatePolicyExtras(policy) {
|
|
358
|
+
if (policy.modelAllowlist !== undefined) {
|
|
359
|
+
assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
|
|
360
|
+
}
|
|
361
|
+
if (policy.rate !== undefined) {
|
|
362
|
+
assertRate(policy.rate, "policy.rate");
|
|
363
|
+
}
|
|
364
|
+
if (policy.profiles !== undefined) {
|
|
365
|
+
if (typeof policy.profiles !== "object" || policy.profiles === null || Array.isArray(policy.profiles)) {
|
|
366
|
+
throw new Error("policy.profiles must be an object of named profiles");
|
|
367
|
+
}
|
|
368
|
+
for (const [name, profile] of Object.entries(policy.profiles)) {
|
|
369
|
+
if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
|
|
370
|
+
throw new Error(`policy.profiles.${name} must be an object`);
|
|
371
|
+
}
|
|
372
|
+
if (profile.modelAllowlist !== undefined) {
|
|
373
|
+
assertModelAllowlist(profile.modelAllowlist, `policy.profiles.${name}.modelAllowlist`);
|
|
374
|
+
}
|
|
375
|
+
if (profile.rate !== undefined) {
|
|
376
|
+
assertRate(profile.rate, `policy.profiles.${name}.rate`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (policy.profileBinding !== undefined) {
|
|
381
|
+
const binding = policy.profileBinding;
|
|
382
|
+
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
|
|
383
|
+
throw new Error("policy.profileBinding must be an object");
|
|
384
|
+
}
|
|
385
|
+
if (typeof binding.default !== "string" || !binding.default.trim()) {
|
|
386
|
+
throw new Error("policy.profileBinding.default must be a profile name");
|
|
387
|
+
}
|
|
388
|
+
for (const field of ["byScope", "byLabel"]) {
|
|
389
|
+
if (binding[field] !== undefined
|
|
390
|
+
&& (typeof binding[field] !== "object" || binding[field] === null || Array.isArray(binding[field]))) {
|
|
391
|
+
throw new Error(`policy.profileBinding.${field} must be an object`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function assertModelAllowlist(value, label) {
|
|
398
|
+
if (!Array.isArray(value) || !value.every((model) => typeof model === "string" && model.trim())) {
|
|
399
|
+
throw new Error(`${label} must be an array of non-empty strings`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function assertRate(value, label) {
|
|
404
|
+
if (typeof value !== "object" || value === null
|
|
405
|
+
|| typeof value.requestsPerMinute !== "number" || value.requestsPerMinute < 1) {
|
|
406
|
+
throw new Error(`${label}.requestsPerMinute must be a positive number`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function resolveAuthProvider(config, providers, cryptoProvider) {
|
|
411
|
+
if (config.auth.provider === "external") {
|
|
412
|
+
if (typeof providers.authProvider?.authenticate !== "function") {
|
|
413
|
+
throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
|
|
414
|
+
}
|
|
415
|
+
return providers.authProvider;
|
|
416
|
+
}
|
|
417
|
+
if (providers.authProvider) {
|
|
418
|
+
// An injected provider overrides the built-in selection.
|
|
419
|
+
return providers.authProvider;
|
|
420
|
+
}
|
|
421
|
+
if (config.auth.provider === "bearer") {
|
|
422
|
+
return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
299
427
|
function createConfiguredCryptoProvider(config) {
|
|
300
428
|
if (config.keys.provider === "external") {
|
|
301
429
|
throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
|
package/packages/core/index.mjs
CHANGED
|
@@ -7,14 +7,19 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
7
7
|
throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
async function protectJson(payload,
|
|
10
|
+
async function protectJson(payload, rawContext = {}) {
|
|
11
|
+
// A per-request policy engine (a named profile selected from identity)
|
|
12
|
+
// overrides the default. It is a control object, NOT data: strip it before
|
|
13
|
+
// anything downstream (tokenize AAD, audit) sees the context.
|
|
14
|
+
const { policyEngine: contextEngine, ...context } = rawContext;
|
|
11
15
|
const effectiveMode = context.mode ?? mode;
|
|
16
|
+
const engine = contextEngine ?? policyEngine;
|
|
12
17
|
const entries = collectStringEntries(payload);
|
|
13
18
|
const detections = await filterEngine.detect({ entries, context });
|
|
14
19
|
const decisions = [];
|
|
15
20
|
|
|
16
21
|
for (const detection of detections) {
|
|
17
|
-
decisions.push(await
|
|
22
|
+
decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
@@ -55,8 +60,12 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
55
60
|
// Holds a bounded raw tail so a detection split across chunk boundaries is
|
|
56
61
|
// caught before the leading part is emitted. maxMatchBytes bounds the
|
|
57
62
|
// guarantee: a single match longer than it may still split across frames.
|
|
58
|
-
function createStreamProtector(
|
|
63
|
+
function createStreamProtector(rawContext = {}) {
|
|
64
|
+
// Strip the control-object policy engine from the data context (see
|
|
65
|
+
// protectJson) so it cannot leak into tokenize AAD or audit.
|
|
66
|
+
const { policyEngine: contextEngine, ...context } = rawContext;
|
|
59
67
|
const effectiveMode = context.mode ?? mode;
|
|
68
|
+
const engine = contextEngine ?? policyEngine;
|
|
60
69
|
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
61
70
|
const maxMatchBytes = context.maxMatchBytes ?? 256;
|
|
62
71
|
const byType = {};
|
|
@@ -76,7 +85,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
76
85
|
async function decideAll(detections) {
|
|
77
86
|
const decisions = [];
|
|
78
87
|
for (const detection of detections) {
|
|
79
|
-
decisions.push(await
|
|
88
|
+
decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
|
|
80
89
|
}
|
|
81
90
|
return decisions;
|
|
82
91
|
}
|
|
@@ -378,9 +387,11 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
|
|
|
378
387
|
timestamp: new Date().toISOString(),
|
|
379
388
|
protocol: context.protocol ?? "custom",
|
|
380
389
|
operation: context.operation ?? "protect",
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
390
|
+
// PII-safe identity built by the auth layer (subject/issuer are keyed
|
|
391
|
+
// HMACs); null when no auth is configured. `profile` is the resolved
|
|
392
|
+
// policy profile name (or null).
|
|
393
|
+
identity: context.identity ?? null,
|
|
394
|
+
profile: context.profile ?? null,
|
|
384
395
|
mode,
|
|
385
396
|
enforced,
|
|
386
397
|
blocked,
|
|
@@ -139,6 +139,113 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
|
|
|
139
139
|
return { created: true, keyFile, rotated: retiredKeys.length > 0 };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// Conformance suite for any cryptoProvider used via keys.provider: external.
|
|
143
|
+
// Adapter authors (e.g. a KMS satellite) run this to self-test against the
|
|
144
|
+
// contract. encrypt/decrypt are always required; hmac is required for
|
|
145
|
+
// tokenization, auth, deterministic tokens, and policy bundles — pass
|
|
146
|
+
// { requireHmac: false } for an encrypt-only provider.
|
|
147
|
+
export async function assertCryptoProviderConformance(provider, { requireHmac = true } = {}) {
|
|
148
|
+
const failures = [];
|
|
149
|
+
const check = async (name, fn) => {
|
|
150
|
+
try {
|
|
151
|
+
await fn();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
failures.push(`${name}: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const assert = (condition, message) => {
|
|
157
|
+
if (!condition) {
|
|
158
|
+
throw new Error(message);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (typeof provider?.encrypt !== "function" || typeof provider?.decrypt !== "function") {
|
|
163
|
+
throw new Error("cryptoProvider must implement encrypt() and decrypt()");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const plaintext = `conformance-${randomBytes(8).toString("hex")}@example.com`;
|
|
167
|
+
const aad = { purpose: "conformance", path: "messages[0].content", type: "email" };
|
|
168
|
+
|
|
169
|
+
const other = `conformance-${randomBytes(8).toString("hex")}@example.org`;
|
|
170
|
+
|
|
171
|
+
await check("encrypt/decrypt round-trip", async () => {
|
|
172
|
+
const envelope = await provider.encrypt({ plaintext, aad });
|
|
173
|
+
assert(envelope && typeof envelope === "object", "encrypt must return an envelope object");
|
|
174
|
+
assert(envelope.kid, "envelope must carry a key id (kid)");
|
|
175
|
+
assert(envelope.aadHash, "envelope must carry an aadHash");
|
|
176
|
+
const back = await provider.decrypt({ envelope, aad });
|
|
177
|
+
assert(back === plaintext, "decrypt did not return the original plaintext");
|
|
178
|
+
// A second, distinct plaintext rules out a decrypt that returns a fixed value.
|
|
179
|
+
const back2 = await provider.decrypt({ envelope: await provider.encrypt({ plaintext: other, aad }), aad });
|
|
180
|
+
assert(back2 === other, "decrypt did not return the second plaintext (fixed/garbage output)");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await check("decrypt rejects a different AAD", async () => {
|
|
184
|
+
const envelope = await provider.encrypt({ plaintext, aad });
|
|
185
|
+
let rejected = false;
|
|
186
|
+
try {
|
|
187
|
+
await provider.decrypt({ envelope, aad: { ...aad, type: "phone" } });
|
|
188
|
+
} catch {
|
|
189
|
+
rejected = true;
|
|
190
|
+
}
|
|
191
|
+
assert(rejected, "decrypt accepted a mismatched AAD (no AAD binding)");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await check("decrypt rejects tampered ciphertext (real AEAD authentication)", async () => {
|
|
195
|
+
const envelope = await provider.encrypt({ plaintext, aad });
|
|
196
|
+
if (typeof envelope.ct !== "string" || envelope.ct.length === 0) {
|
|
197
|
+
return; // provider uses a non-ct envelope shape; the AAD check above still applies
|
|
198
|
+
}
|
|
199
|
+
// Flip a byte of the ciphertext; a real AEAD provider fails the auth tag.
|
|
200
|
+
const buf = Buffer.from(envelope.ct, "base64url");
|
|
201
|
+
buf[0] ^= 0xff;
|
|
202
|
+
let rejected = false;
|
|
203
|
+
try {
|
|
204
|
+
await provider.decrypt({ envelope: { ...envelope, ct: buf.toString("base64url") }, aad });
|
|
205
|
+
} catch {
|
|
206
|
+
rejected = true;
|
|
207
|
+
}
|
|
208
|
+
assert(rejected, "decrypt accepted tampered ciphertext (no AEAD authentication)");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (requireHmac) {
|
|
212
|
+
if (typeof provider.hmac !== "function") {
|
|
213
|
+
failures.push("hmac: provider does not implement hmac() (required for tokenization/auth/bundles)");
|
|
214
|
+
} else {
|
|
215
|
+
await check("hmac is deterministic and data-dependent", async () => {
|
|
216
|
+
const a = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
|
|
217
|
+
const b = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
|
|
218
|
+
assert(typeof a === "string" && a.length > 0, "hmac must return a non-empty string");
|
|
219
|
+
assert(a === b, "hmac is not deterministic for the same (data, domain)");
|
|
220
|
+
// Different data MUST give different output — else tokens/identities collide.
|
|
221
|
+
const c = await provider.hmac({ data: "y", domain: "haechi:conformance:v1" });
|
|
222
|
+
assert(a !== c, "hmac ignores the data argument (same output for different data)");
|
|
223
|
+
});
|
|
224
|
+
await check("hmac separates domains", async () => {
|
|
225
|
+
const a = await provider.hmac({ data: "x", domain: "haechi:conformance:a" });
|
|
226
|
+
const b = await provider.hmac({ data: "x", domain: "haechi:conformance:b" });
|
|
227
|
+
assert(a !== b, "hmac does not separate domains (same output for different domains)");
|
|
228
|
+
});
|
|
229
|
+
await check("hmac requires a domain", async () => {
|
|
230
|
+
for (const badDomain of ["", undefined, null]) {
|
|
231
|
+
let rejected = false;
|
|
232
|
+
try {
|
|
233
|
+
await provider.hmac({ data: "x", domain: badDomain });
|
|
234
|
+
} catch {
|
|
235
|
+
rejected = true;
|
|
236
|
+
}
|
|
237
|
+
assert(rejected, `hmac accepted an invalid domain (${JSON.stringify(badDomain)})`);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (failures.length > 0) {
|
|
244
|
+
throw new Error(`cryptoProvider conformance failed:\n- ${failures.join("\n- ")}`);
|
|
245
|
+
}
|
|
246
|
+
return { ok: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
142
249
|
export function canonicalize(value) {
|
|
143
250
|
if (Array.isArray(value)) {
|
|
144
251
|
return `[${value.map((item) => canonicalize(item)).join(",")}]`;
|
|
@@ -130,6 +130,88 @@ export function createPolicyEngine(policy) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Compiles the base policy plus every named profile into ready policy engines
|
|
134
|
+
// and a resolver that maps an identity to one. A profile inherits the base
|
|
135
|
+
// policy's presets/actions and overrides on top (so a profile need only state
|
|
136
|
+
// what differs). `transform` (e.g. applyPrivacyProfile) is applied to each
|
|
137
|
+
// compiled policy source before buildPolicy.
|
|
138
|
+
export function createPolicyProfiles(policyConfig = {}, { transform } = {}) {
|
|
139
|
+
const { profiles = {}, profileBinding = null, ...baseSource } = policyConfig;
|
|
140
|
+
const apply = (source) => (transform ? transform(source) : source);
|
|
141
|
+
|
|
142
|
+
const baseEngine = createPolicyEngine(buildPolicy(apply(baseSource)));
|
|
143
|
+
const profileNames = Object.keys(profiles);
|
|
144
|
+
const engines = new Map();
|
|
145
|
+
|
|
146
|
+
for (const name of profileNames) {
|
|
147
|
+
const override = profiles[name] ?? {};
|
|
148
|
+
const merged = {
|
|
149
|
+
...baseSource,
|
|
150
|
+
...override,
|
|
151
|
+
// Profile presets replace the base presets when given; actions merge over
|
|
152
|
+
// the base via buildPolicy's strengthen-only rules.
|
|
153
|
+
actions: { ...(baseSource.actions ?? {}), ...(override.actions ?? {}) },
|
|
154
|
+
modelAllowlist: override.modelAllowlist ?? baseSource.modelAllowlist,
|
|
155
|
+
rate: override.rate ?? baseSource.rate
|
|
156
|
+
};
|
|
157
|
+
engines.set(name, {
|
|
158
|
+
policyEngine: createPolicyEngine(buildPolicy(apply(merged))),
|
|
159
|
+
modelAllowlist: merged.modelAllowlist ?? null,
|
|
160
|
+
rate: merged.rate ?? null
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (profileBinding) {
|
|
165
|
+
if (!profileBinding.default || !engines.has(profileBinding.default)) {
|
|
166
|
+
throw new Error("policy.profileBinding.default must name a declared profile");
|
|
167
|
+
}
|
|
168
|
+
for (const map of [profileBinding.byScope ?? {}, profileBinding.byLabel ?? {}]) {
|
|
169
|
+
for (const [key, target] of Object.entries(map)) {
|
|
170
|
+
if (!engines.has(target)) {
|
|
171
|
+
throw new Error(`policy.profileBinding maps ${key} to unknown profile: ${target}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (profileNames.length > 0) {
|
|
176
|
+
throw new Error("policy.profiles requires policy.profileBinding with a default");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const base = {
|
|
180
|
+
policyEngine: baseEngine,
|
|
181
|
+
modelAllowlist: baseSource.modelAllowlist ?? null,
|
|
182
|
+
rate: baseSource.rate ?? null
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
base,
|
|
187
|
+
hasProfiles: profileNames.length > 0,
|
|
188
|
+
// Resolve identity → { profile, policyEngine, modelAllowlist, rate }.
|
|
189
|
+
// Order: scope match → label match → default. Without profiles or identity,
|
|
190
|
+
// the base policy applies.
|
|
191
|
+
resolve(identity) {
|
|
192
|
+
if (!profileBinding) {
|
|
193
|
+
return { profile: null, ...base };
|
|
194
|
+
}
|
|
195
|
+
if (identity) {
|
|
196
|
+
for (const scope of identity.scopes ?? []) {
|
|
197
|
+
const name = profileBinding.byScope?.[scope];
|
|
198
|
+
if (name) {
|
|
199
|
+
return { profile: name, ...engines.get(name) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const [key, value] of Object.entries(identity.labels ?? {})) {
|
|
203
|
+
const name = profileBinding.byLabel?.[`${key}=${value}`];
|
|
204
|
+
if (name) {
|
|
205
|
+
return { profile: name, ...engines.get(name) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const fallback = profileBinding.default;
|
|
210
|
+
return { profile: fallback, ...engines.get(fallback) };
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
133
215
|
export function validatePolicy(policy) {
|
|
134
216
|
if (!policy || typeof policy !== "object") {
|
|
135
217
|
throw new Error("Policy must be an object");
|