@emilia-protocol/gate 0.9.0 → 0.9.1

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.
@@ -0,0 +1,365 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — independent re-performance verifier (EP-GATE-REPERFORMANCE-v1).
4
+ *
5
+ * The auditor's "trust nothing, recompute everything" tool. Re-performance is
6
+ * the highest form of audit evidence: instead of reading the deployer's report
7
+ * and believing it, the auditor takes the raw evidence entries and REDOES the
8
+ * work — rebuilds the hash chain link by link and checks it with the evidence
9
+ * log's own verifier, cryptographically re-verifies every receipt / signoff /
10
+ * quorum document an entry carries, recomputes the decision counts from
11
+ * scratch, and diffs those recomputed numbers against what a report pack
12
+ * claims. Vendor logs assert; this recomputes.
13
+ *
14
+ * INDEPENDENCE BY CONSTRUCTION: counts are recomputed here from the raw
15
+ * entries — this module deliberately does NOT import metering.js or any report
16
+ * builder, so a bug in a report builder cannot hide from its own cross-check.
17
+ * The only imports are the primitives being re-driven: the evidence log's real
18
+ * verify() and the protocol verifiers (verifyEmiliaReceipt /
19
+ * verifyWebAuthnSignoff / verifyQuorum).
20
+ *
21
+ * FAIL CLOSED everywhere:
22
+ * - one broken chain link fails the chain from that point — the remainder is
23
+ * not vouched for;
24
+ * - one failed re-verification is a NAMED failure ({hash, reason}), never
25
+ * absorbed into a pass count;
26
+ * - an entry that references a receipt but does not carry the verifiable
27
+ * material is counted `not_reverifiable` — never silently passed;
28
+ * - a reported pack of unknown @version is refused, never fuzzily matched.
29
+ *
30
+ * HONESTY BOUNDARY (carried inside the artifact): this SUPPORTS an auditor's
31
+ * re-performance procedure. It does not conclude, opine, or certify — and it
32
+ * cannot establish log COMPLETENESS (a chain rewritten in full, or entries
33
+ * withheld before supply, is invisible from the entries alone; anchor the head
34
+ * hash externally to close that), nor runtime freshness / one-time consumption
35
+ * (properties of the moment of decision, not re-performable after the fact).
36
+ *
37
+ * Deterministic: pure over (entries, issuerKeys); time enters only through the
38
+ * injectable `now` (generated_at). No sampling, no randomness.
39
+ */
40
+ import { createEvidenceLog } from '../evidence.js';
41
+
42
+ // The protocol verifiers being re-driven. Same resolution pattern as
43
+ // packages/gate/index.js: prefer the published package, fall back to the
44
+ // in-repo source so the monorepo works without a node_modules link.
45
+ const { verifyEmiliaReceipt } = await import('@emilia-protocol/require-receipt')
46
+ .catch(() => import('../../require-receipt/index.js'));
47
+ const { verifyWebAuthnSignoff, verifyQuorum } = await import('@emilia-protocol/verify')
48
+ .catch(() => import('../../verify/index.js'));
49
+
50
+ export const REPERFORMANCE_VERSION = 'EP-GATE-REPERFORMANCE-v1';
51
+
52
+ // Wire-format identifiers of the packs compareToReported() can cross-check.
53
+ // Restated locally — NOT imported from the builders — so this module remains a
54
+ // standalone auditor tool with zero dependency on the code it checks.
55
+ const USAGE_PACK_VERSION = 'EP-GATE-USAGE-v1';
56
+ const UNDERWRITER_PACK_VERSION = 'EP-GATE-UNDERWRITER-ATTESTATION-v1';
57
+
58
+ /** Deterministic key-sorted copy — output is byte-stable regardless of entry order. */
59
+ function sortedCounts(obj) {
60
+ const out = {};
61
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k];
62
+ return out;
63
+ }
64
+
65
+ /** The checks a verifier reported false, as one stable, named string. */
66
+ function failingChecks(checks) {
67
+ const bad = Object.keys(checks || {}).filter((k) => checks[k] === false).sort();
68
+ return bad.length ? bad.join('+') : 'unspecified';
69
+ }
70
+
71
+ /**
72
+ * The verifiable payloads an entry CARRIES (not the verification RESULTS the
73
+ * gate recorded about them — `tier_evidence` / `rejected` are the gate's own
74
+ * conclusions and are deliberately ignored: re-performance trusts nothing the
75
+ * gate concluded, only material it can re-verify itself).
76
+ *
77
+ * Recognized carriers:
78
+ * - entry.receipt full EP-RECEIPT-v1 document
79
+ * - entry.signoff / payload.signoff WebAuthn device-signoff evidence,
80
+ * with the approver key at entry.approver_public_key or
81
+ * receipt.payload.approver_public_key
82
+ * - entry.quorum / payload.quorum full EP-QUORUM-v1 document
83
+ */
84
+ function collectMaterial(e) {
85
+ const out = [];
86
+ const receipt = e.receipt;
87
+ const payload = receipt && typeof receipt === 'object' && receipt.payload
88
+ && typeof receipt.payload === 'object' ? receipt.payload : null;
89
+ if (receipt !== undefined && receipt !== null) out.push({ type: 'receipt', doc: receipt });
90
+ const signoff = e.signoff ?? payload?.signoff;
91
+ if (signoff !== undefined && signoff !== null) {
92
+ out.push({
93
+ type: 'signoff',
94
+ doc: signoff,
95
+ key: e.approver_public_key ?? payload?.approver_public_key ?? null,
96
+ });
97
+ }
98
+ const quorum = e.quorum ?? payload?.quorum;
99
+ if (quorum !== undefined && quorum !== null) out.push({ type: 'quorum', doc: quorum });
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Re-verify one carried payload. Returns null on pass, a NAMED reason on fail.
105
+ * Freshness is intentionally not re-enforced (maxAgeSec: 0): it was a runtime
106
+ * property of the decision moment and cannot be re-performed at audit time —
107
+ * what IS re-performed is the cryptography (signature, issuer trust, action
108
+ * binding, ceremony checks).
109
+ */
110
+ function reverifyMaterial(m, { issuerKeys, action, rpId }) {
111
+ if (m.type === 'receipt') {
112
+ const r = verifyEmiliaReceipt(m.doc, {
113
+ trustedKeys: issuerKeys, allowInlineKey: false, action, maxAgeSec: 0,
114
+ });
115
+ return r.ok ? null : `receipt:${r.reason}`;
116
+ }
117
+ if (m.type === 'signoff') {
118
+ // No pinned/carried approver key -> nothing to verify AGAINST. Named
119
+ // failure, never a silent pass.
120
+ if (!m.key || typeof m.key !== 'string') return 'signoff:no_approver_key_carried';
121
+ const r = verifyWebAuthnSignoff(m.doc, m.key, rpId ? { rpId } : {});
122
+ return r.valid ? null : `signoff:${r.error ? `error:${r.error}` : `checks_failed:${failingChecks(r.checks)}`}`;
123
+ }
124
+ if (m.type === 'quorum') {
125
+ const r = verifyQuorum(m.doc, rpId ? { rpId } : {});
126
+ return r.valid ? null : `quorum:checks_failed:${failingChecks(r.checks)}`;
127
+ }
128
+ /* c8 ignore next */
129
+ return `unknown_material:${m.type}`; // unreachable; fail closed regardless
130
+ }
131
+
132
+ /**
133
+ * Re-perform the evidence: rebuild the chain, re-verify carried cryptographic
134
+ * material, recompute the counts. Async because the chain is rebuilt through
135
+ * the evidence log's own (async) record().
136
+ *
137
+ * The entries MUST be the complete log from genesis (`evidence.all()` or a
138
+ * full export). A partial slice fails the chain (fail closed) because its
139
+ * first link cannot chain from 'genesis'.
140
+ *
141
+ * Chain method — drives evidence.js's REAL verify(), twice over:
142
+ * 1. every supplied entry body (its own seq/prev_hash included, its hash
143
+ * stripped) is re-recorded into a fresh createEvidenceLog(), which
144
+ * recomputes the canonical-JSON sha256 for that exact body; a recomputed
145
+ * hash that differs from the SUPPLIED hash is a tampered/forged entry —
146
+ * broken from that point;
147
+ * 2. the rebuilt log's own verify() then walks the whole chain, catching
148
+ * link-level attacks the per-entry recompute cannot (a removed entry, or
149
+ * an entry rewritten WITH a consistently recomputed hash — its successor's
150
+ * prev_hash no longer matches).
151
+ *
152
+ * @param {Array<object>} entries the full evidence log (evidence.all())
153
+ * @param {object} [o]
154
+ * @param {string[]} [o.issuerKeys=[]] pinned base64url SPKI issuer keys, sourced
155
+ * by the AUDITOR out of band — never from the entries themselves
156
+ * @param {number|function} [o.now=Date.now] clock for generated_at (pin for determinism)
157
+ * @param {string} [o.rpId] bind carried WebAuthn assertions to this relying-party id
158
+ * @returns {Promise<object>} EP-GATE-REPERFORMANCE-v1 document:
159
+ * { chain: {ok, entries, head}, receipts: {reverified, failed, not_reverifiable},
160
+ * counts: {allows, denies, replays_blocked, by_action_type}, ... }
161
+ */
162
+ export async function reperformEvidence(entries = [], { issuerKeys = [], now = Date.now, rpId = null } = {}) {
163
+ if (!Array.isArray(entries)) {
164
+ throw new Error('reperform: entries must be an array (evidence.all() or a full export)');
165
+ }
166
+ if (!Array.isArray(issuerKeys)) {
167
+ throw new Error('reperform: issuerKeys must be an array of base64url SPKI issuer keys');
168
+ }
169
+
170
+ /* ------------------------------- chain -------------------------------- */
171
+ const rebuilt = createEvidenceLog();
172
+ let broken = null;
173
+ for (let i = 0; i < entries.length; i++) {
174
+ const e = entries[i];
175
+ if (!e || typeof e !== 'object' || Array.isArray(e)) { broken = { at: i, seq: null, reason: 'not_an_object' }; break; }
176
+ const seq = Number.isInteger(e.seq) ? e.seq : null;
177
+ if (seq === null) { broken = { at: i, seq: null, reason: 'missing_seq' }; break; }
178
+ if (typeof e.prev_hash !== 'string' || e.prev_hash.length === 0) { broken = { at: i, seq, reason: 'missing_prev_hash' }; break; }
179
+ if (typeof e.hash !== 'string' || e.hash.length === 0) { broken = { at: i, seq, reason: 'missing_hash' }; break; }
180
+ // Re-record the EXACT supplied body (own seq/prev_hash override the fresh
181
+ // log's defaults via the record() spread) so the recomputed hash is over
182
+ // byte-identical canonical material.
183
+ const { hash: suppliedHash, ...body } = e;
184
+ const rec = await rebuilt.record(body);
185
+ if (rec.hash !== suppliedHash) { broken = { at: i, seq, reason: 'hash_mismatch' }; break; }
186
+ }
187
+ // Drive the REAL evidence.js verifier over the rebuilt chain — the link walk
188
+ // (prev_hash continuity from 'genesis') is its judgment, not a reimplementation.
189
+ const rv = rebuilt.verify();
190
+ if (!broken && !rv.ok) broken = { at: rv.at ?? null, seq: rv.at ?? null, reason: rv.reason };
191
+ const chain = {
192
+ ok: !broken,
193
+ entries: entries.length,
194
+ // A broken chain vouches for NO head — even a valid prefix's.
195
+ head: !broken ? (rv.head ?? null) : null,
196
+ ...(broken ? { broken_at: broken.at, broken_seq: broken.seq, reason: broken.reason } : {}),
197
+ };
198
+
199
+ /* ------------------- receipts + counts (from scratch) ------------------ */
200
+ const receipts = { reverified: 0, failed: [], not_reverifiable: 0, no_receipt_presented: 0 };
201
+ const warnings = [];
202
+ const byAction = Object.create(null);
203
+ let allows = 0;
204
+ let denies = 0;
205
+ let replaysBlocked = 0;
206
+
207
+ entries.forEach((e, index) => {
208
+ if (!e || typeof e !== 'object' || Array.isArray(e)) { warnings.push({ index, reason: 'not_an_object' }); return; }
209
+ if (typeof e.kind !== 'string' || e.kind.length === 0) { warnings.push({ index, reason: 'missing_kind' }); return; }
210
+
211
+ // Counts: guarded decisions only ('not_guarded' pass-throughs ran without
212
+ // the control; execution records are provenance, not enforcement). Fail
213
+ // closed: only a literal `true` counts as an allow.
214
+ if (e.kind === 'decision' && e.reason !== 'not_guarded') {
215
+ if (e.allow === true) allows += 1; else denies += 1;
216
+ if (e.reason === 'replay_refused') replaysBlocked += 1;
217
+ const action = typeof e.action === 'string' && e.action ? e.action : 'unknown';
218
+ byAction[action] = (byAction[action] || 0) + 1;
219
+ }
220
+
221
+ // Re-verification: any entry that carries material gets it re-verified;
222
+ // ALL carried payloads must verify for the entry to count as reverified —
223
+ // each failure is a named {hash, reason}, never absorbed.
224
+ const hash = typeof e.hash === 'string' && e.hash ? e.hash : null;
225
+ const material = collectMaterial(e);
226
+ if (material.length > 0) {
227
+ let allOk = true;
228
+ for (const m of material) {
229
+ const reason = reverifyMaterial(m, {
230
+ issuerKeys, action: typeof e.action === 'string' && e.action ? e.action : null, rpId,
231
+ });
232
+ if (reason) { allOk = false; receipts.failed.push({ hash, reason }); }
233
+ }
234
+ if (allOk) receipts.reverified += 1;
235
+ return;
236
+ }
237
+ // No material carried. A decision that REFERENCES a receipt (receipt_id
238
+ // recorded) but does not carry it cannot be re-performed — surfaced as
239
+ // not_reverifiable, never silently passed. A decision where no receipt was
240
+ // presented at all (pass-through, receipt_required refusal) has nothing to
241
+ // re-verify and is reported separately for transparency.
242
+ if (e.kind === 'decision') {
243
+ if (e.receipt_id !== null && e.receipt_id !== undefined) receipts.not_reverifiable += 1;
244
+ else receipts.no_receipt_presented += 1;
245
+ }
246
+ });
247
+
248
+ const nowMs = typeof now === 'function' ? now() : now;
249
+
250
+ return {
251
+ '@version': REPERFORMANCE_VERSION,
252
+ product: 'EMILIA Gate',
253
+ generated_at: new Date(nowMs).toISOString(),
254
+ honesty: {
255
+ reperforms:
256
+ 'Independent recomputation from the supplied evidence entries: the hash chain is rebuilt '
257
+ + 'and checked with the evidence log\'s own verifier, carried receipt/signoff/quorum material '
258
+ + 'is cryptographically re-verified against auditor-pinned issuer keys, and decision counts '
259
+ + 'are recomputed from scratch without reference to any report builder.',
260
+ does_not_establish: [
261
+ 'Completeness of the log: a chain rewritten in full, or entries withheld before they were supplied, is not detectable from the entries alone. Anchor the head hash externally to close this.',
262
+ 'Runtime freshness or one-time consumption: those were properties of the moment of decision and cannot be re-performed after the fact; cryptographic material is re-verified, runtime state is not.',
263
+ 'Issuer and approver key custody, enrollment, or identity proofing, which remain external trust roots the auditor supplies out of band.',
264
+ 'The business correctness or wisdom of any authorized action.',
265
+ ],
266
+ status: 'Support for an audit re-performance procedure. This document does not conclude, opine, or certify; any conclusion is the auditor\'s.',
267
+ },
268
+ input: {
269
+ entries_supplied: entries.length,
270
+ issuer_keys_pinned: issuerKeys.length,
271
+ expects: 'the complete evidence log from genesis (evidence.all() or a full export)',
272
+ },
273
+ chain,
274
+ receipts,
275
+ counts: {
276
+ allows,
277
+ denies,
278
+ replays_blocked: replaysBlocked,
279
+ by_action_type: sortedCounts(byAction),
280
+ },
281
+ integrity_warnings: warnings,
282
+ };
283
+ }
284
+
285
+ /** Extract the recomputed counts, fail closed on anything unrecognized. */
286
+ function countsFrom(recomputed) {
287
+ if (!recomputed || typeof recomputed !== 'object') {
288
+ throw new Error('compareToReported: recomputed must be the reperformEvidence result (or its counts)');
289
+ }
290
+ const c = recomputed['@version'] === REPERFORMANCE_VERSION ? recomputed.counts : recomputed;
291
+ if (!c || typeof c !== 'object') {
292
+ throw new Error('compareToReported: recomputed carries no counts');
293
+ }
294
+ for (const k of ['allows', 'denies', 'replays_blocked']) {
295
+ if (!Number.isFinite(c[k]) || c[k] < 0) {
296
+ throw new Error(`compareToReported: recomputed.${k} must be a finite number >= 0 — refusing to compare`);
297
+ }
298
+ }
299
+ const by = c.by_action_type;
300
+ return { ...c, by_action_type: by && typeof by === 'object' && !Array.isArray(by) ? by : {} };
301
+ }
302
+
303
+ /**
304
+ * Diff recomputed counts against a reported pack — the auditor's tie-out.
305
+ *
306
+ * Accepts an EP-GATE-USAGE-v1 pack (meterUsage output or the signed-ready
307
+ * buildUsageStatement body) or an EP-GATE-UNDERWRITER-ATTESTATION-v1 pack.
308
+ * Only the OVERLAPPING NUMERIC fields are compared; an unknown pack @version
309
+ * is refused (fail closed), never fuzzily matched. A reported field that is
310
+ * missing or non-numeric is itself a named drift — a stripped pack can never
311
+ * silently match.
312
+ *
313
+ * The comparison is meaningful only when the recomputation ran over exactly
314
+ * the entries the reported pack was built from (same slice, same window);
315
+ * scoping the slice is the auditor's procedure, not this function's.
316
+ *
317
+ * @param {object} recomputed reperformEvidence() result (or its .counts)
318
+ * @param {object} reportedPack usage or underwriter pack
319
+ * @returns {{match: boolean, pack_version: string, drift: Array<{field, reported, recomputed}>}}
320
+ */
321
+ export function compareToReported(recomputed, reportedPack) {
322
+ const counts = countsFrom(recomputed);
323
+ if (!reportedPack || typeof reportedPack !== 'object') {
324
+ throw new Error('compareToReported: reportedPack must be an object');
325
+ }
326
+ const version = reportedPack['@version'];
327
+ const drift = [];
328
+ const check = (field, reported, rec) => {
329
+ if (!Number.isFinite(reported) || reported !== rec) {
330
+ drift.push({ field, reported: reported === undefined ? null : reported, recomputed: rec });
331
+ }
332
+ };
333
+
334
+ if (version === USAGE_PACK_VERSION) {
335
+ check('allows', reportedPack.allows, counts.allows);
336
+ check('denies', reportedPack.denies, counts.denies);
337
+ check('replays_blocked', reportedPack.replays_blocked, counts.replays_blocked);
338
+ check('protected_actions', reportedPack.protected_actions, counts.allows + counts.denies);
339
+ const reportedBy = reportedPack.by_action_type;
340
+ const byOk = reportedBy && typeof reportedBy === 'object' && !Array.isArray(reportedBy);
341
+ const keys = new Set([
342
+ ...Object.keys(counts.by_action_type),
343
+ ...(byOk ? Object.keys(reportedBy) : []),
344
+ ]);
345
+ for (const k of [...keys].sort()) {
346
+ // An absent key in a well-formed histogram means zero occurrences.
347
+ const rep = byOk && reportedBy[k] !== undefined ? reportedBy[k] : 0;
348
+ check(`by_action_type.${k}`, rep, counts.by_action_type[k] ?? 0);
349
+ }
350
+ } else if (version === UNDERWRITER_PACK_VERSION) {
351
+ check('volume.guarded_decisions', reportedPack.volume?.guarded_decisions, counts.allows + counts.denies);
352
+ check('volume.allowed', reportedPack.volume?.allowed, counts.allows);
353
+ check('volume.denied', reportedPack.volume?.denied, counts.denies);
354
+ check('denials.total', reportedPack.denials?.total, counts.denies);
355
+ check('replay.attempts_blocked', reportedPack.replay?.attempts_blocked, counts.replays_blocked);
356
+ } else {
357
+ throw new Error(
358
+ `compareToReported: unknown pack @version ${JSON.stringify(version ?? null)} — refusing to compare (fail closed)`,
359
+ );
360
+ }
361
+
362
+ return { match: drift.length === 0, pack_version: version, drift };
363
+ }
364
+
365
+ export default { REPERFORMANCE_VERSION, reperformEvidence, compareToReported };