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
package/packages/audit/index.mjs
CHANGED
|
@@ -30,9 +30,73 @@ const FORBIDDEN_KEYS = new Set([
|
|
|
30
30
|
"scopes", "labels"
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
|
-
|
|
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("
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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(",")}]`;
|