haechi 0.6.0 → 0.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.
- package/README.ko.md +8 -1
- package/README.md +8 -1
- package/docs/README.md +1 -0
- package/docs/current/api-stability.ko.md +14 -2
- package/docs/current/api-stability.md +14 -2
- package/docs/current/release-0.6-implementation-scope.ko.md +4 -4
- package/docs/current/release-0.6-implementation-scope.md +4 -4
- 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-0.8-implementation-scope.ko.md +145 -0
- package/docs/current/release-0.8-implementation-scope.md +145 -0
- package/docs/current/release-process.ko.md +60 -6
- package/docs/current/release-process.md +60 -6
- package/docs/current/risk-register-release-gate.ko.md +4 -2
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/threat-model.ko.md +5 -2
- package/docs/current/threat-model.md +5 -2
- package/examples/crypto-kms-reference/README.md +13 -0
- package/haechi.config.example.json +6 -1
- package/package.json +9 -1
- package/packages/audit/index.mjs +99 -6
- package/packages/auth/index.mjs +45 -0
- package/packages/cli/bin/haechi.mjs +40 -8
- package/packages/cli/runtime.mjs +33 -3
- package/packages/crypto/index.mjs +107 -0
- package/scripts/release-checksums.mjs +100 -0
package/packages/audit/index.mjs
CHANGED
|
@@ -7,20 +7,55 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
7
7
|
|
|
8
8
|
const FORBIDDEN_KEYS = new Set(["value", "plaintext", "payload", "content", "message", "prompt", "secret"]);
|
|
9
9
|
|
|
10
|
-
export function createJsonlAuditSink({ path }) {
|
|
10
|
+
export function createJsonlAuditSink({ path, anchor = null }) {
|
|
11
11
|
if (!path) {
|
|
12
12
|
throw new Error("JSONL audit sink requires path");
|
|
13
13
|
}
|
|
14
|
+
const anchorMode = anchor?.mode ?? "none";
|
|
15
|
+
const anchorPath = anchor?.path ?? null;
|
|
16
|
+
const everyRecords = anchor?.everyRecords ?? 1;
|
|
17
|
+
if (!["none", "file", "stdout"].includes(anchorMode)) {
|
|
18
|
+
throw new Error(`Invalid audit anchor mode: ${anchorMode}`);
|
|
19
|
+
}
|
|
20
|
+
if (anchorMode === "file" && !anchorPath) {
|
|
21
|
+
throw new Error("audit anchor mode 'file' requires an anchor path");
|
|
22
|
+
}
|
|
23
|
+
// The sink is a public export reachable via auditSink injection, so it
|
|
24
|
+
// validates everyRecords itself rather than trusting normalizeConfig.
|
|
25
|
+
if (!Number.isInteger(everyRecords) || everyRecords < 1) {
|
|
26
|
+
throw new Error("audit anchor everyRecords must be a positive integer");
|
|
27
|
+
}
|
|
14
28
|
|
|
15
29
|
let writeQueue = Promise.resolve();
|
|
16
30
|
|
|
31
|
+
async function writeAnchor(record) {
|
|
32
|
+
const { sequence, eventHash } = record.auditIntegrity;
|
|
33
|
+
// Tamper-evidence against tail truncation: the chain head is appended to a
|
|
34
|
+
// separate append-only stream, so deleting trailing records leaves the
|
|
35
|
+
// chain shorter than the last anchored sequence.
|
|
36
|
+
if (anchorMode === "none" || sequence % everyRecords !== 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const line = `${JSON.stringify({ sequence, eventHash, timestamp: record.timestamp })}\n`;
|
|
40
|
+
if (anchorMode === "stdout") {
|
|
41
|
+
process.stdout.write(line);
|
|
42
|
+
} else {
|
|
43
|
+
await mkdir(dirname(anchorPath), { recursive: true });
|
|
44
|
+
// 0600 on creation, like the key/lock files. Note this only matters for
|
|
45
|
+
// confidentiality of the timeline — tamper-evidence still requires the
|
|
46
|
+
// anchor to live on append-only/separate media (see docs).
|
|
47
|
+
await appendFile(anchorPath, line, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
return {
|
|
18
52
|
id: "haechi.audit.jsonl",
|
|
19
53
|
version: "0.1.0",
|
|
20
54
|
capabilities: {
|
|
21
55
|
writesAudit: true,
|
|
22
56
|
writesPlaintext: false,
|
|
23
|
-
|
|
57
|
+
appendOnly: true,
|
|
58
|
+
integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
|
|
24
59
|
},
|
|
25
60
|
async record(event) {
|
|
26
61
|
const write = writeQueue.then(async () => {
|
|
@@ -28,6 +63,7 @@ export function createJsonlAuditSink({ path }) {
|
|
|
28
63
|
await withFileLock(`${path}.lock`, async () => {
|
|
29
64
|
const record = await buildIntegrityRecord(path, sanitizeAudit(event));
|
|
30
65
|
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
66
|
+
await writeAnchor(record);
|
|
31
67
|
});
|
|
32
68
|
});
|
|
33
69
|
writeQueue = write.catch(() => {});
|
|
@@ -87,7 +123,12 @@ export function sanitizeAudit(value) {
|
|
|
87
123
|
return value;
|
|
88
124
|
}
|
|
89
125
|
|
|
90
|
-
export async function verifyAuditChain(path) {
|
|
126
|
+
export async function verifyAuditChain(path, { anchorPath = null } = {}) {
|
|
127
|
+
// The anchor stream (if provided) records the chain head at past points; a
|
|
128
|
+
// chain shorter than the last anchor, or a hash that disagrees with an
|
|
129
|
+
// anchor, is tail truncation / tampering the chain alone cannot catch.
|
|
130
|
+
const anchors = anchorPath ? await readAnchors(anchorPath) : null;
|
|
131
|
+
|
|
91
132
|
const lines = createInterface({
|
|
92
133
|
input: createReadStream(path, { encoding: "utf8" }),
|
|
93
134
|
crlfDelay: Infinity
|
|
@@ -122,14 +163,66 @@ export async function verifyAuditChain(path) {
|
|
|
122
163
|
return { valid: false, records, reason: "event hash mismatch" };
|
|
123
164
|
}
|
|
124
165
|
|
|
166
|
+
if (anchors && anchors.bySequence.has(expectedSequence)
|
|
167
|
+
&& anchors.bySequence.get(expectedSequence) !== eventHash) {
|
|
168
|
+
return { valid: false, records, reason: `anchor hash mismatch at sequence ${expectedSequence}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
125
171
|
expectedPreviousHash = eventHash;
|
|
126
172
|
expectedSequence += 1;
|
|
127
173
|
records += 1;
|
|
128
174
|
}
|
|
129
175
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
176
|
+
if (anchors && anchors.lastSequence > records) {
|
|
177
|
+
return {
|
|
178
|
+
valid: false,
|
|
179
|
+
records,
|
|
180
|
+
reason: `tail truncation: chain has ${records} records but anchor attests sequence ${anchors.lastSequence}`
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// headHash anchors the chain externally. With anchorPath, truncation back to
|
|
185
|
+
// the last anchor is now detected; the residual gap is records written after
|
|
186
|
+
// the last anchor.
|
|
187
|
+
const result = { valid: true, records, headHash: expectedPreviousHash };
|
|
188
|
+
if (anchors) {
|
|
189
|
+
result.anchored = { count: anchors.bySequence.size, lastSequence: anchors.lastSequence };
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function readAnchors(anchorPath) {
|
|
195
|
+
const bySequence = new Map();
|
|
196
|
+
let lastSequence = 0;
|
|
197
|
+
try {
|
|
198
|
+
const lines = createInterface({
|
|
199
|
+
input: createReadStream(anchorPath, { encoding: "utf8" }),
|
|
200
|
+
crlfDelay: Infinity
|
|
201
|
+
});
|
|
202
|
+
for await (const line of lines) {
|
|
203
|
+
if (!line.trim()) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// A crash can leave a partial trailing anchor line; tolerate it (skip)
|
|
207
|
+
// rather than failing the whole verification. The chain check plus the
|
|
208
|
+
// remaining valid anchors still bound truncation detection.
|
|
209
|
+
let anchor;
|
|
210
|
+
try {
|
|
211
|
+
anchor = JSON.parse(line);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (typeof anchor.sequence === "number" && typeof anchor.eventHash === "string") {
|
|
216
|
+
bySequence.set(anchor.sequence, anchor.eventHash);
|
|
217
|
+
lastSequence = Math.max(lastSequence, anchor.sequence);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code !== "ENOENT") {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { bySequence, lastSequence };
|
|
133
226
|
}
|
|
134
227
|
|
|
135
228
|
async function buildIntegrityRecord(path, event) {
|
package/packages/auth/index.mjs
CHANGED
|
@@ -117,6 +117,51 @@ export async function buildIdentity(record, cryptoProvider) {
|
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// PII-safe identity builder for EXTERNAL auth providers (e.g. the haechi-auth-jwt
|
|
121
|
+
// satellite). Core owns identity construction so the keyed-HMAC domain and the
|
|
122
|
+
// identity shape stay authoritative here — a satellite supplies raw claims and
|
|
123
|
+
// never sees or stores the IDENTITY_DOMAIN. subject/issuer become keyed HMACs;
|
|
124
|
+
// the raw values are never returned. Throws (fail-closed) on a missing
|
|
125
|
+
// cryptoProvider.hmac, an empty subject/issuer, an invalid type, bad scopes, or
|
|
126
|
+
// a disallowed label.
|
|
127
|
+
export async function buildExternalIdentity(
|
|
128
|
+
{ provider, subject, issuer, type = "user", scopes = [], labels = {}, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS },
|
|
129
|
+
cryptoProvider
|
|
130
|
+
) {
|
|
131
|
+
if (typeof cryptoProvider?.hmac !== "function") {
|
|
132
|
+
throw new Error("buildExternalIdentity requires a cryptoProvider with hmac()");
|
|
133
|
+
}
|
|
134
|
+
if (!provider || typeof provider !== "string") {
|
|
135
|
+
throw new Error("identity requires a non-empty provider string");
|
|
136
|
+
}
|
|
137
|
+
if (!subject || typeof subject !== "string") {
|
|
138
|
+
throw new Error("identity requires a non-empty subject");
|
|
139
|
+
}
|
|
140
|
+
if (!issuer || typeof issuer !== "string") {
|
|
141
|
+
throw new Error("identity requires a non-empty issuer");
|
|
142
|
+
}
|
|
143
|
+
if (!VALID_IDENTITY_TYPES.has(type)) {
|
|
144
|
+
throw new Error(`Invalid identity type: ${type} (expected user | service | agent)`);
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string" && scope.trim())) {
|
|
147
|
+
throw new Error("scopes must be an array of non-empty strings");
|
|
148
|
+
}
|
|
149
|
+
validateLabels(labels, allowedLabelKeys);
|
|
150
|
+
|
|
151
|
+
const subjectHash = await cryptoProvider.hmac({ data: subject, domain: IDENTITY_DOMAIN });
|
|
152
|
+
const issuerHash = await cryptoProvider.hmac({ data: issuer, domain: IDENTITY_DOMAIN });
|
|
153
|
+
return {
|
|
154
|
+
// Non-PII, stable per subject: derived from the keyed subject hash.
|
|
155
|
+
id: `${provider}:${subjectHash.slice(0, 16)}`,
|
|
156
|
+
type,
|
|
157
|
+
subjectHash,
|
|
158
|
+
issuerHash,
|
|
159
|
+
provider,
|
|
160
|
+
scopes,
|
|
161
|
+
labels
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
120
165
|
function bearerTokenFromRequest(request) {
|
|
121
166
|
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
122
167
|
if (typeof header !== "string") {
|
|
@@ -147,19 +147,27 @@ async function reportCommand(argv) {
|
|
|
147
147
|
async function auditVerifyCommand(argv) {
|
|
148
148
|
const options = parseOptions(argv);
|
|
149
149
|
let auditPath = options.audit ?? options.path;
|
|
150
|
-
|
|
150
|
+
let anchorPath = typeof options.anchor === "string" ? options.anchor : null;
|
|
151
|
+
if (!auditPath || (options.anchor === true && !anchorPath)) {
|
|
151
152
|
try {
|
|
152
|
-
|
|
153
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
154
|
+
auditPath = auditPath ?? config.audit.path;
|
|
155
|
+
// --anchor with no value (or no flag at all) falls back to the configured
|
|
156
|
+
// anchor path when anchoring is enabled.
|
|
157
|
+
if (!anchorPath && config.audit.anchor.mode === "file") {
|
|
158
|
+
anchorPath = config.audit.anchor.path;
|
|
159
|
+
}
|
|
153
160
|
} catch {
|
|
154
|
-
auditPath = ".haechi/audit.jsonl";
|
|
161
|
+
auditPath = auditPath ?? ".haechi/audit.jsonl";
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
164
|
|
|
158
|
-
const result = await verifyAuditChain(auditPath);
|
|
165
|
+
const result = await verifyAuditChain(auditPath, { anchorPath });
|
|
159
166
|
writeJson({
|
|
160
167
|
ok: result.valid,
|
|
161
168
|
command: "audit-verify",
|
|
162
169
|
auditPath,
|
|
170
|
+
anchorPath,
|
|
163
171
|
result
|
|
164
172
|
});
|
|
165
173
|
if (!result.valid) {
|
|
@@ -208,17 +216,30 @@ async function statusCommand(argv) {
|
|
|
208
216
|
warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
|
|
209
217
|
}
|
|
210
218
|
|
|
211
|
-
const
|
|
219
|
+
const anchorEnabled = config.audit.anchor.mode === "file";
|
|
220
|
+
const audit = {
|
|
221
|
+
path: config.audit.path,
|
|
222
|
+
exists: false,
|
|
223
|
+
chain: null,
|
|
224
|
+
anchor: { mode: config.audit.anchor.mode, path: anchorEnabled ? config.audit.anchor.path : null }
|
|
225
|
+
};
|
|
212
226
|
try {
|
|
213
227
|
await stat(config.audit.path);
|
|
214
228
|
audit.exists = true;
|
|
215
|
-
audit.chain = await verifyAuditChain(config.audit.path
|
|
229
|
+
audit.chain = await verifyAuditChain(config.audit.path, {
|
|
230
|
+
anchorPath: anchorEnabled ? config.audit.anchor.path : null
|
|
231
|
+
});
|
|
216
232
|
if (!audit.chain.valid) {
|
|
217
233
|
warnings.push(`audit chain verification failed: ${audit.chain.reason}`);
|
|
218
234
|
}
|
|
219
235
|
} catch {
|
|
220
236
|
// No audit file yet is a normal pre-first-run state, not a warning.
|
|
221
237
|
}
|
|
238
|
+
if (config.audit.anchor.mode === "none") {
|
|
239
|
+
warnings.push("audit.anchor.mode is none: tail truncation of the audit log cannot be detected");
|
|
240
|
+
} else if (config.audit.anchor.mode === "file") {
|
|
241
|
+
warnings.push("audit.anchor: real tail-truncation defense requires the anchor on append-only or separate media; on the same writable filesystem an attacker can truncate both files together");
|
|
242
|
+
}
|
|
222
243
|
|
|
223
244
|
writeJson({
|
|
224
245
|
ok: true,
|
|
@@ -566,9 +587,9 @@ const COMMAND_HELP = {
|
|
|
566
587
|
summary: "Summarize audit events without raw payloads."
|
|
567
588
|
},
|
|
568
589
|
"audit-verify": {
|
|
569
|
-
usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]",
|
|
590
|
+
usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--anchor [path]] [--config haechi.config.json]",
|
|
570
591
|
summary: "Verify the audit hash chain; print validity, record count, and head hash.",
|
|
571
|
-
detail: "Exit 4 on a broken chain.
|
|
592
|
+
detail: "Exit 4 on a broken chain. With --anchor (or audit.anchor.mode: file in config) it cross-checks the anchor stream and detects tail truncation back to the last anchor. The anchor only adds real defense when kept on append-only or separate media — on the same writable filesystem an attacker can truncate both files together."
|
|
572
593
|
},
|
|
573
594
|
status: {
|
|
574
595
|
usage: "haechi status [--config haechi.config.json]",
|
|
@@ -698,6 +719,17 @@ Tokenization (model sees token, caller sees plaintext)
|
|
|
698
719
|
tokenVault.detokenizeResponses restore request-issued tokens in the response
|
|
699
720
|
(needs responseProtection.enabled)
|
|
700
721
|
|
|
722
|
+
Audit integrity
|
|
723
|
+
audit.anchor.mode none | file | stdout (default none)
|
|
724
|
+
file/stdout anchor the chain head so tail
|
|
725
|
+
truncation is detected (haechi audit-verify --anchor).
|
|
726
|
+
Real defense needs the anchor on append-only or
|
|
727
|
+
separate media; same-filesystem anchors can be
|
|
728
|
+
truncated together. stdout mode is for long-running
|
|
729
|
+
commands (proxy), not JSON-emitting ones.
|
|
730
|
+
audit.anchor.path .haechi/audit.anchor.jsonl (mode: file)
|
|
731
|
+
audit.anchor.everyRecords anchor cadence (default 1)
|
|
732
|
+
|
|
701
733
|
Privacy + MCP
|
|
702
734
|
privacy.profile kr-pipa | eu-gdpr | us-general | null
|
|
703
735
|
mcp.allowedMethods client-callable method allowlist
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -60,7 +60,12 @@ export function defaultConfig() {
|
|
|
60
60
|
},
|
|
61
61
|
audit: {
|
|
62
62
|
sink: "jsonl",
|
|
63
|
-
path: ".haechi/audit.jsonl"
|
|
63
|
+
path: ".haechi/audit.jsonl",
|
|
64
|
+
anchor: {
|
|
65
|
+
mode: "none",
|
|
66
|
+
path: ".haechi/audit.anchor.jsonl",
|
|
67
|
+
everyRecords: 1
|
|
68
|
+
}
|
|
64
69
|
},
|
|
65
70
|
tokenVault: {
|
|
66
71
|
provider: "local",
|
|
@@ -117,7 +122,18 @@ export function createRuntime(config, providers = {}) {
|
|
|
117
122
|
const normalized = normalizeConfig(config);
|
|
118
123
|
const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
|
|
119
124
|
assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
|
|
120
|
-
|
|
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
|
+
});
|
|
121
137
|
assertProvider("auditSink", auditSink, ["record"]);
|
|
122
138
|
const tokenVault = providers.tokenVault ?? createLocalTokenVault({
|
|
123
139
|
path: normalized.tokenVault.path,
|
|
@@ -214,7 +230,11 @@ export function normalizeConfig(config) {
|
|
|
214
230
|
},
|
|
215
231
|
audit: {
|
|
216
232
|
...defaultConfig().audit,
|
|
217
|
-
...(config.audit ?? {})
|
|
233
|
+
...(config.audit ?? {}),
|
|
234
|
+
anchor: {
|
|
235
|
+
...defaultConfig().audit.anchor,
|
|
236
|
+
...(config.audit?.anchor ?? {})
|
|
237
|
+
}
|
|
218
238
|
},
|
|
219
239
|
tokenVault: {
|
|
220
240
|
...defaultConfig().tokenVault,
|
|
@@ -248,6 +268,16 @@ export function normalizeConfig(config) {
|
|
|
248
268
|
if (merged.audit.sink !== "jsonl") {
|
|
249
269
|
throw new Error("Current implementation only supports jsonl audit sink");
|
|
250
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
|
+
}
|
|
251
281
|
if (merged.tokenVault.provider !== "local") {
|
|
252
282
|
throw new Error("0.2 only supports local token vault provider");
|
|
253
283
|
}
|
|
@@ -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(",")}]`;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generate or verify a SHA256SUMS manifest for release artifacts.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/release-checksums.mjs <file...> # print "<hash> <name>" lines
|
|
5
|
+
// node scripts/release-checksums.mjs --check SHA256SUMS # verify files against a manifest
|
|
6
|
+
//
|
|
7
|
+
// Standard `<sha256-hex> <basename>` format (two spaces), so `sha256sum -c`
|
|
8
|
+
// and `shasum -a 256 -c` interoperate with what this prints.
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { createReadStream } from "node:fs";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
export async function sha256File(path) {
|
|
17
|
+
const hash = createHash("sha256");
|
|
18
|
+
for await (const chunk of createReadStream(path)) {
|
|
19
|
+
hash.update(chunk);
|
|
20
|
+
}
|
|
21
|
+
return hash.digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatManifestLine(hashHex, name) {
|
|
25
|
+
return `${hashHex} ${name}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseManifest(text) {
|
|
29
|
+
return text
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((line) => {
|
|
34
|
+
const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new Error(`Malformed SHA256SUMS line: ${line}`);
|
|
37
|
+
}
|
|
38
|
+
return { hash: match[1], name: match[2] };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function generateManifest(files) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
lines.push(formatManifestLine(await sha256File(file), basename(file)));
|
|
46
|
+
}
|
|
47
|
+
return `${lines.join("\n")}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function verifyManifest(manifestPath) {
|
|
51
|
+
const baseDir = dirname(manifestPath);
|
|
52
|
+
const entries = parseManifest(await readFile(manifestPath, "utf8"));
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
// A manifest is untrusted input: never hash a path that escapes the
|
|
56
|
+
// manifest's own directory (no absolute paths, no `../` traversal).
|
|
57
|
+
const rel = relative(baseDir, join(baseDir, entry.name));
|
|
58
|
+
if (isAbsolute(entry.name) || rel.startsWith("..")) {
|
|
59
|
+
results.push({ name: entry.name, ok: false, reason: "unsafe path" });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let actual = null;
|
|
63
|
+
try {
|
|
64
|
+
actual = await sha256File(join(baseDir, entry.name));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
results.push({ name: entry.name, ok: false, reason: error.code === "ENOENT" ? "missing" : error.message });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
results.push({ name: entry.name, ok: actual === entry.hash, reason: actual === entry.hash ? null : "hash mismatch" });
|
|
70
|
+
}
|
|
71
|
+
return { ok: results.every((r) => r.ok), results };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main(argv) {
|
|
75
|
+
if (argv[0] === "--check") {
|
|
76
|
+
const manifestPath = argv[1];
|
|
77
|
+
if (!manifestPath) {
|
|
78
|
+
throw new Error("--check requires a SHA256SUMS path");
|
|
79
|
+
}
|
|
80
|
+
const { ok, results } = await verifyManifest(manifestPath);
|
|
81
|
+
for (const r of results) {
|
|
82
|
+
process.stderr.write(`${r.ok ? "OK " : "FAIL"} ${r.name}${r.reason ? ` (${r.reason})` : ""}\n`);
|
|
83
|
+
}
|
|
84
|
+
process.exitCode = ok ? 0 : 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (argv.length === 0) {
|
|
88
|
+
throw new Error("usage: release-checksums.mjs <file...> | --check SHA256SUMS");
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(await generateManifest(argv));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Run only as a CLI (not when imported by tests). fileURLToPath handles
|
|
94
|
+
// Windows paths and URL encoding that a raw `file://` compare would miss.
|
|
95
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
96
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
97
|
+
process.stderr.write(`release-checksums: ${error.message}\n`);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
});
|
|
100
|
+
}
|