@emilia-protocol/gate 0.9.2 → 0.9.4
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.md +35 -7
- package/aec-execution.js +301 -0
- package/challenge-store.js +70 -0
- package/enterprise.js +4 -1
- package/ep-assure.mjs +65 -0
- package/evidence.js +273 -4
- package/index.js +125 -23
- package/package.json +20 -2
- package/reliance-kernel.js +114 -0
- package/reports/assurance-package.js +287 -0
- package/store-postgres.js +45 -26
- package/store.js +97 -23
package/evidence.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @emilia-protocol/gate — evidence log (tamper-
|
|
2
|
+
* @emilia-protocol/gate — evidence log (tamper-detecting audit of decisions).
|
|
3
3
|
* @license Apache-2.0
|
|
4
4
|
*
|
|
5
5
|
* Every gate decision — allow or deny — appends a hash-chained record. Each
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* breaks the chain and `verify()` catches it. This is the compliance and
|
|
8
8
|
* insurance artifact: a provable account of exactly which consequential actions
|
|
9
9
|
* were authorized, refused, and why. Default sink is in-memory; pass `sink` to
|
|
10
|
-
* stream records to
|
|
10
|
+
* stream records to storage. This local chain detects alteration only when the
|
|
11
|
+
* verifier receives its complete history from genesis; a sink alone cannot
|
|
12
|
+
* prevent truncation, restart-from-genesis, or cross-replica forks. Use
|
|
13
|
+
* createAtomicEvidenceLog for a shared production head.
|
|
11
14
|
*
|
|
12
15
|
* Hashing is over CANONICAL JSON (sorted keys) so the same logical record hashes
|
|
13
16
|
* identically across systems and languages — a plain JSON.stringify is
|
|
@@ -39,8 +42,20 @@ export function createEvidenceLog({ sink, strict = false } = {}) {
|
|
|
39
42
|
let prev = 'genesis';
|
|
40
43
|
|
|
41
44
|
return {
|
|
45
|
+
// A sink can persist this process-local chain, but it cannot make the head
|
|
46
|
+
// atomic across replicas or continue it after restart. Never advertise the
|
|
47
|
+
// local logger as a fleet-safe production ledger.
|
|
48
|
+
durable: false,
|
|
49
|
+
persisted: typeof sink === 'function' && strict === true,
|
|
50
|
+
strict: strict === true,
|
|
51
|
+
forkAware: false,
|
|
52
|
+
atomicAppend: false,
|
|
42
53
|
async record(entry) {
|
|
43
|
-
|
|
54
|
+
// Deep-copy the caller's entry: a shallow spread embeds live references to
|
|
55
|
+
// nested objects, so a caller mutating them after record() would silently
|
|
56
|
+
// corrupt the hash-chained evidence record (and anything a sink persisted).
|
|
57
|
+
const snapshot = entry && typeof entry === 'object' ? structuredClone(entry) : entry;
|
|
58
|
+
const body = { seq: records.length, prev_hash: prev, ...snapshot };
|
|
44
59
|
const hash = sha256hex(canonical(body));
|
|
45
60
|
const rec = { ...body, hash };
|
|
46
61
|
if (sink) {
|
|
@@ -81,4 +96,258 @@ export function createEvidenceLog({ sink, strict = false } = {}) {
|
|
|
81
96
|
};
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
|
|
99
|
+
const LOG_HEX_256 = /^[0-9a-f]{64}$/;
|
|
100
|
+
const LOG_RESERVED_FIELDS = new Set(['seq', 'prev_hash', 'record_id', 'hash']);
|
|
101
|
+
|
|
102
|
+
/** Verify one logger acknowledgement independently of the logger that emitted it. */
|
|
103
|
+
export function verifyEvidenceRecord(record, { atomicRequired = false, expectedEntry } = {}) {
|
|
104
|
+
try {
|
|
105
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
106
|
+
|| !Number.isSafeInteger(record.seq) || record.seq < 0
|
|
107
|
+
|| (record.prev_hash !== 'genesis' && !LOG_HEX_256.test(record.prev_hash))
|
|
108
|
+
|| !LOG_HEX_256.test(record.hash)) return false;
|
|
109
|
+
const hasRecordId = Object.prototype.hasOwnProperty.call(record, 'record_id');
|
|
110
|
+
if ((atomicRequired && !hasRecordId)
|
|
111
|
+
|| (hasRecordId && (typeof record.record_id !== 'string'
|
|
112
|
+
|| record.record_id.length < 16 || record.record_id.length > 256))) return false;
|
|
113
|
+
const { hash, ...body } = record;
|
|
114
|
+
if (sha256hex(canonical(body)) !== hash) return false;
|
|
115
|
+
if (expectedEntry !== undefined) {
|
|
116
|
+
if (!expectedEntry || typeof expectedEntry !== 'object' || Array.isArray(expectedEntry)) return false;
|
|
117
|
+
const entry = Object.fromEntries(
|
|
118
|
+
Object.entries(record).filter(([key]) => !LOG_RESERVED_FIELDS.has(key)),
|
|
119
|
+
);
|
|
120
|
+
if (canonical(entry) !== canonical(expectedEntry)) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertLogEntry(entry) {
|
|
129
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
130
|
+
throw new Error('atomic evidence entry must be an object');
|
|
131
|
+
}
|
|
132
|
+
for (const field of LOG_RESERVED_FIELDS) {
|
|
133
|
+
if (Object.prototype.hasOwnProperty.call(entry, field)) {
|
|
134
|
+
throw new Error(`atomic evidence entry must not supply reserved field ${field}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const stack = [{ value: entry, depth: 0 }];
|
|
138
|
+
const seen = new WeakSet();
|
|
139
|
+
let nodes = 0;
|
|
140
|
+
let stringBytes = 0;
|
|
141
|
+
while (stack.length) {
|
|
142
|
+
const { value, depth } = stack.pop();
|
|
143
|
+
if (++nodes > 50000 || depth > 64) throw new Error('atomic evidence entry exceeds resource limits');
|
|
144
|
+
if (value === null || typeof value === 'boolean') continue;
|
|
145
|
+
if (typeof value === 'number') {
|
|
146
|
+
if (!Number.isSafeInteger(value)) throw new Error('atomic evidence entry contains a non-safe integer');
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === 'string') {
|
|
150
|
+
stringBytes += Buffer.byteLength(value, 'utf8');
|
|
151
|
+
if (stringBytes > 1024 * 1024) throw new Error('atomic evidence entry exceeds string limit');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (typeof value !== 'object') throw new Error('atomic evidence entry is not canonical JSON');
|
|
155
|
+
if (seen.has(value)) throw new Error('atomic evidence entry contains a cycle or alias');
|
|
156
|
+
seen.add(value);
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
for (const child of value) stack.push({ value: child, depth: depth + 1 });
|
|
159
|
+
} else {
|
|
160
|
+
for (const [key, child] of Object.entries(value)) {
|
|
161
|
+
stringBytes += Buffer.byteLength(key, 'utf8');
|
|
162
|
+
if (stringBytes > 1024 * 1024) throw new Error('atomic evidence entry exceeds string limit');
|
|
163
|
+
stack.push({ value: child, depth: depth + 1 });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function validateAtomicRecord(record, expectedId, expectedEntry, expectedRecord = null) {
|
|
170
|
+
return record?.record_id === expectedId
|
|
171
|
+
&& verifyEvidenceRecord(record, { atomicRequired: true, expectedEntry })
|
|
172
|
+
&& (expectedRecord === null || canonical(record) === canonical(expectedRecord));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validHead(head) {
|
|
176
|
+
return head === null || Boolean(head && Number.isSafeInteger(head.seq) && head.seq >= 0 && LOG_HEX_256.test(head.hash));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Fleet-safe, fail-closed evidence log over an atomic shared-head backend.
|
|
181
|
+
*
|
|
182
|
+
* Backend contract (all operations are scoped by streamId):
|
|
183
|
+
* readHead(streamId) -> null | { seq, hash }
|
|
184
|
+
* getById(streamId, recordId) -> null | record
|
|
185
|
+
* appendIfHead(streamId, expectedHeadHash|null, record) -> boolean
|
|
186
|
+
* readAll(streamId) -> record[] // optional, for verify/all
|
|
187
|
+
*
|
|
188
|
+
* appendIfHead MUST atomically compare the current head, append the immutable
|
|
189
|
+
* record, reject duplicate record_id, and advance the head in one durable
|
|
190
|
+
* transaction. A true return MUST provide immediate read-after-write visibility
|
|
191
|
+
* through getById. `backend.durable === true` is a deployment capability
|
|
192
|
+
* assertion; this module tests the protocol but cannot prove storage hardware
|
|
193
|
+
* semantics.
|
|
194
|
+
*/
|
|
195
|
+
export function createAtomicEvidenceLog(backend, {
|
|
196
|
+
streamId = 'emilia-gate',
|
|
197
|
+
maxRetries = 32,
|
|
198
|
+
recordIdFactory = () => crypto.randomUUID(),
|
|
199
|
+
} = {}) {
|
|
200
|
+
for (const method of ['readHead', 'getById', 'appendIfHead']) {
|
|
201
|
+
if (typeof backend?.[method] !== 'function') {
|
|
202
|
+
throw new Error(`createAtomicEvidenceLog: backend must implement async ${method}()`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (typeof streamId !== 'string' || !streamId || streamId.length > 256) {
|
|
206
|
+
throw new Error('createAtomicEvidenceLog: streamId must be a non-empty string of at most 256 characters');
|
|
207
|
+
}
|
|
208
|
+
if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || maxRetries > 1024) {
|
|
209
|
+
throw new Error('createAtomicEvidenceLog: maxRetries must be an integer from 1 to 1024');
|
|
210
|
+
}
|
|
211
|
+
if (typeof recordIdFactory !== 'function') {
|
|
212
|
+
throw new Error('createAtomicEvidenceLog: recordIdFactory must be a function');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function recover(recordId, snapshot, expectedRecord = null) {
|
|
216
|
+
const existing = await backend.getById(streamId, recordId);
|
|
217
|
+
if (existing === null || existing === undefined) return null;
|
|
218
|
+
if (!validateAtomicRecord(existing, recordId, snapshot, expectedRecord)) {
|
|
219
|
+
throw new Error('atomic evidence backend returned a conflicting record_id');
|
|
220
|
+
}
|
|
221
|
+
return structuredClone(existing);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
durable: backend.durable === true,
|
|
226
|
+
persisted: backend.durable === true,
|
|
227
|
+
strict: true,
|
|
228
|
+
forkAware: true,
|
|
229
|
+
atomicAppend: true,
|
|
230
|
+
streamId,
|
|
231
|
+
|
|
232
|
+
async record(entry) {
|
|
233
|
+
const snapshot = structuredClone(entry);
|
|
234
|
+
assertLogEntry(snapshot);
|
|
235
|
+
const recordId = recordIdFactory();
|
|
236
|
+
if (typeof recordId !== 'string' || recordId.length < 16 || recordId.length > 256) {
|
|
237
|
+
throw new Error('atomic evidence record id must be a string of 16 to 256 characters');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
241
|
+
const recovered = await recover(recordId, snapshot);
|
|
242
|
+
if (recovered) return recovered;
|
|
243
|
+
|
|
244
|
+
const head = await backend.readHead(streamId);
|
|
245
|
+
if (!validHead(head)) throw new Error('atomic evidence backend returned a malformed head');
|
|
246
|
+
const seq = head === null ? 0 : head.seq + 1;
|
|
247
|
+
const prevHash = head === null ? 'genesis' : head.hash;
|
|
248
|
+
const body = { seq, prev_hash: prevHash, record_id: recordId, ...snapshot };
|
|
249
|
+
const record = { ...body, hash: sha256hex(canonical(body)) };
|
|
250
|
+
try {
|
|
251
|
+
if ((await backend.appendIfHead(streamId, head?.hash ?? null, record)) === true) {
|
|
252
|
+
const persisted = await recover(recordId, snapshot, record);
|
|
253
|
+
if (persisted) return persisted;
|
|
254
|
+
throw new Error('atomic_evidence_append_not_observable');
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// The append may have linearized before its response was lost. Recover
|
|
258
|
+
// by stable id; if no record is visible, fail closed and let the caller
|
|
259
|
+
// retain/freeze its execution reservation.
|
|
260
|
+
const afterError = await recover(recordId, snapshot, record);
|
|
261
|
+
if (afterError) return afterError;
|
|
262
|
+
const wrapped = new Error('atomic_evidence_append_indeterminate');
|
|
263
|
+
wrapped.cause = error;
|
|
264
|
+
throw wrapped;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
throw new Error('atomic_evidence_contention_limit');
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async all() {
|
|
271
|
+
if (typeof backend.readAll !== 'function') throw new Error('atomic evidence backend does not expose readAll()');
|
|
272
|
+
const records = await backend.readAll(streamId);
|
|
273
|
+
if (!Array.isArray(records)) throw new Error('atomic evidence backend returned malformed history');
|
|
274
|
+
return structuredClone(records);
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async verify() {
|
|
278
|
+
try {
|
|
279
|
+
if (typeof backend.readAll !== 'function') return { ok: false, reason: 'read_all_unavailable' };
|
|
280
|
+
const records = await backend.readAll(streamId);
|
|
281
|
+
if (!Array.isArray(records)) return { ok: false, reason: 'malformed_history' };
|
|
282
|
+
let prev = 'genesis';
|
|
283
|
+
const ids = new Set();
|
|
284
|
+
for (let index = 0; index < records.length; index++) {
|
|
285
|
+
const record = records[index];
|
|
286
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
287
|
+
|| record.seq !== index || record.prev_hash !== prev
|
|
288
|
+
|| typeof record.record_id !== 'string' || ids.has(record.record_id)) {
|
|
289
|
+
return { ok: false, at: index, reason: 'sequence_or_predecessor_mismatch' };
|
|
290
|
+
}
|
|
291
|
+
const { hash, ...body } = record;
|
|
292
|
+
if (!LOG_HEX_256.test(hash) || sha256hex(canonical(body)) !== hash) {
|
|
293
|
+
return { ok: false, at: index, reason: 'hash_mismatch' };
|
|
294
|
+
}
|
|
295
|
+
ids.add(record.record_id);
|
|
296
|
+
prev = hash;
|
|
297
|
+
}
|
|
298
|
+
const head = await backend.readHead(streamId);
|
|
299
|
+
const expectedHead = records.length === 0 ? null : { seq: records.length - 1, hash: prev };
|
|
300
|
+
if (canonical(head) !== canonical(expectedHead)) return { ok: false, reason: 'head_mismatch' };
|
|
301
|
+
return { ok: true, length: records.length, head: expectedHead?.hash ?? null };
|
|
302
|
+
} catch {
|
|
303
|
+
return { ok: false, reason: 'backend_read_failed_or_malformed' };
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** In-memory contract model for tests. It is intentionally not durable. */
|
|
310
|
+
export function createMemoryAtomicEvidenceBackend() {
|
|
311
|
+
const streams = new Map();
|
|
312
|
+
const state = (streamId) => {
|
|
313
|
+
if (!streams.has(streamId)) streams.set(streamId, { records: [], byId: new Map() });
|
|
314
|
+
return streams.get(streamId);
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
durable: false,
|
|
318
|
+
async readHead(streamId) {
|
|
319
|
+
const records = state(streamId).records;
|
|
320
|
+
const record = records[records.length - 1];
|
|
321
|
+
return record ? { seq: record.seq, hash: record.hash } : null;
|
|
322
|
+
},
|
|
323
|
+
async getById(streamId, recordId) {
|
|
324
|
+
return structuredClone(state(streamId).byId.get(recordId) ?? null);
|
|
325
|
+
},
|
|
326
|
+
async appendIfHead(streamId, expectedHeadHash, record) {
|
|
327
|
+
const s = state(streamId);
|
|
328
|
+
const head = s.records[s.records.length - 1] ?? null;
|
|
329
|
+
const actual = head?.hash ?? null;
|
|
330
|
+
if (actual !== expectedHeadHash || s.byId.has(record.record_id)
|
|
331
|
+
|| record.seq !== s.records.length || record.prev_hash !== (head?.hash ?? 'genesis')) return false;
|
|
332
|
+
const snapshot = structuredClone(record);
|
|
333
|
+
s.records.push(snapshot);
|
|
334
|
+
s.byId.set(snapshot.record_id, snapshot);
|
|
335
|
+
return true;
|
|
336
|
+
},
|
|
337
|
+
async readAll(streamId) { return structuredClone(state(streamId).records); },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const __atomicEvidenceSecurityInternals = Object.freeze({
|
|
342
|
+
canonical,
|
|
343
|
+
assertLogEntry,
|
|
344
|
+
validateAtomicRecord,
|
|
345
|
+
validHead,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
export default {
|
|
349
|
+
createEvidenceLog,
|
|
350
|
+
verifyEvidenceRecord,
|
|
351
|
+
createAtomicEvidenceLog,
|
|
352
|
+
createMemoryAtomicEvidenceBackend,
|
|
353
|
+
};
|
package/index.js
CHANGED
|
@@ -44,7 +44,7 @@ const {
|
|
|
44
44
|
const { verifyWebAuthnSignoff, verifyQuorum } = await import('@emilia-protocol/verify')
|
|
45
45
|
.catch(() => import('../verify/index.js'));
|
|
46
46
|
import { MemoryConsumptionStore } from './store.js';
|
|
47
|
-
import { createEvidenceLog } from './evidence.js';
|
|
47
|
+
import { createAtomicEvidenceLog, createEvidenceLog, createMemoryAtomicEvidenceBackend } from './evidence.js';
|
|
48
48
|
import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
|
|
49
49
|
import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
|
|
50
50
|
import { buildReliancePacket, ADMISSIBILITY_VERDICTS } from './reliance-packet.js';
|
|
@@ -54,8 +54,9 @@ import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
|
54
54
|
import { classifyRetention, buildRetentionExport } from './retention.js';
|
|
55
55
|
import { createDefaultActionControlManifest, findActionControl, validateActionControlManifest } from './action-control-manifest.js';
|
|
56
56
|
|
|
57
|
-
export { MemoryConsumptionStore, createEvidenceLog };
|
|
58
|
-
export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
|
|
57
|
+
export { MemoryConsumptionStore, createEvidenceLog, createAtomicEvidenceLog, createMemoryAtomicEvidenceBackend };
|
|
58
|
+
export { createDurableConsumptionStore, createMemoryBackend, DURABLE_CONSUMPTION_VERSION } from './store.js';
|
|
59
|
+
export { createDurableChallengeStore, challengeStorageKey, challengeBodyDigest, DURABLE_CHALLENGE_STORE_VERSION } from './challenge-store.js';
|
|
59
60
|
export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
60
61
|
export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
|
|
61
62
|
export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
|
|
@@ -163,9 +164,26 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
|
|
|
163
164
|
* verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
|
|
164
165
|
* with the receipt rather than being pinned by the relying party.
|
|
165
166
|
*
|
|
167
|
+
* TRUST-LAUNDERING GUARD: an approver key carried INSIDE the receipt proves
|
|
168
|
+
* only that whoever minted the receipt also holds that key — it is NOT proof
|
|
169
|
+
* the relying party trusts that human. Crediting an elevated tier off such a
|
|
170
|
+
* key would collapse VERIFIED into ACCEPTED (any party can mint a fresh
|
|
171
|
+
* keypair, self-sign a signoff, and embed both). So path (b) elevates the
|
|
172
|
+
* tier ONLY when either: (i) the caller explicitly opts in with
|
|
173
|
+
* `allowEmbeddedApproverKeys:true` (the documented self-contained mode,
|
|
174
|
+
* DEFAULT OFF); or (ii) every embedded approver key that would earn the
|
|
175
|
+
* credit is present in the relying party's PINNED approver key set
|
|
176
|
+
* (opts.approverKeys). With no pin and no opt-in, path (b) may still VERIFY
|
|
177
|
+
* the signoff/quorum, but it does NOT elevate above `software`. Fail-closed.
|
|
178
|
+
*
|
|
166
179
|
* @param {object} doc the EP-RECEIPT-v1 document
|
|
167
180
|
* @param {object} [opts]
|
|
168
|
-
* @param {object} [opts.approverKeys] pinned approver keys for path (a)
|
|
181
|
+
* @param {object} [opts.approverKeys] pinned approver keys for path (a) and the
|
|
182
|
+
* path-(b) fallback: a receipt-embedded approver key elevates the tier only if
|
|
183
|
+
* it is one of these pinned keys (unless allowEmbeddedApproverKeys is set)
|
|
184
|
+
* @param {boolean} [opts.allowEmbeddedApproverKeys=false] explicit opt-in to the
|
|
185
|
+
* self-contained mode where an UNPINNED approver key carried inside the receipt
|
|
186
|
+
* may still elevate the path-(b) tier. DEFAULT OFF (fail-closed).
|
|
169
187
|
* @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
|
|
170
188
|
* @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
|
|
171
189
|
* @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
|
|
@@ -185,30 +203,43 @@ export function receiptAssuranceTier(doc, opts = {}) {
|
|
|
185
203
|
// --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
|
|
186
204
|
const p = doc?.payload || {};
|
|
187
205
|
const verifyOpts = opts.rpId ? { rpId: opts.rpId } : {};
|
|
206
|
+
// The relying party's PINNED approver public keys (base64url SPKI-DER strings).
|
|
207
|
+
// An embedded approver key elevates the tier only if it is in this set, unless
|
|
208
|
+
// the caller explicitly opts into the self-contained mode.
|
|
209
|
+
const allowEmbedded = opts.allowEmbeddedApproverKeys === true;
|
|
210
|
+
const pinnedKeys = pinnedApproverKeySet(opts.approverKeys);
|
|
211
|
+
const keyIsTrusted = (k) => allowEmbedded || (typeof k === 'string' && pinnedKeys.has(k));
|
|
188
212
|
|
|
189
213
|
// quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
|
|
190
214
|
// under payload.quorum or payload.claim.quorum. It only counts if it is a full
|
|
191
215
|
// quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
|
|
192
216
|
// returns valid. A bare {signers,threshold} block has no members to verify and
|
|
193
|
-
// therefore CANNOT be credited quorum.
|
|
217
|
+
// therefore CANNOT be credited quorum. The cryptographic verification runs
|
|
218
|
+
// regardless (so `detail.quorum` reports validity), but the tier elevates only
|
|
219
|
+
// when every member's embedded approver key is pinned (or the caller opted in).
|
|
194
220
|
const q = p.quorum || p.claim?.quorum;
|
|
195
221
|
if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
|
|
196
222
|
const qr = verifyQuorum(q, verifyOpts);
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
const membersTrusted = allowEmbedded
|
|
224
|
+
|| (Array.isArray(q.members) && q.members.length > 0
|
|
225
|
+
&& q.members.every((m) => keyIsTrusted(m?.approver_public_key)));
|
|
226
|
+
detail.quorum = { valid: qr.valid, checks: qr.checks, embedded_keys_trusted: membersTrusted };
|
|
227
|
+
if (qr.valid && membersTrusted) detail.tier = 'quorum';
|
|
199
228
|
}
|
|
200
229
|
|
|
201
230
|
// class_a: a verifiable WebAuthn device signoff. The signoff evidence is
|
|
202
231
|
// {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
|
|
203
|
-
// or alongside it (payload.approver_public_key).
|
|
232
|
+
// or alongside it (payload.approver_public_key). That key elevates the tier only
|
|
233
|
+
// when it is pinned by the relying party (or the caller opted into embedded keys).
|
|
204
234
|
if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
|
|
205
235
|
const so = p.signoff || p.claim?.signoff;
|
|
206
236
|
if (isSignoffEvidence(so)) {
|
|
207
237
|
const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
|
|
208
238
|
if (key) {
|
|
209
239
|
const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
|
|
210
|
-
|
|
211
|
-
|
|
240
|
+
const trusted = keyIsTrusted(key);
|
|
241
|
+
detail.signoff = { valid: sr.valid, checks: sr.checks, embedded_key_trusted: trusted };
|
|
242
|
+
if (sr.valid && trusted && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
|
|
212
243
|
}
|
|
213
244
|
}
|
|
214
245
|
}
|
|
@@ -216,6 +247,34 @@ export function receiptAssuranceTier(doc, opts = {}) {
|
|
|
216
247
|
return opts.detail ? detail : detail.tier;
|
|
217
248
|
}
|
|
218
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The set of PINNED approver public keys (base64url SPKI-DER strings) a relying
|
|
252
|
+
* party trusts, from the same `approverKeys` map path (a) uses. Accepts either a
|
|
253
|
+
* map of { keyId: { public_key } } (the EP-ASSURANCE-PROOF-v1 shape) or a plain
|
|
254
|
+
* array/set of key strings. Used to decide whether a receipt-embedded approver
|
|
255
|
+
* key may elevate the path-(b) tier. Never throws.
|
|
256
|
+
*/
|
|
257
|
+
function pinnedApproverKeySet(approverKeys) {
|
|
258
|
+
const out = new Set();
|
|
259
|
+
if (!approverKeys) return out;
|
|
260
|
+
if (approverKeys instanceof Set) {
|
|
261
|
+
for (const k of approverKeys) if (typeof k === 'string' && k) out.add(k);
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(approverKeys)) {
|
|
265
|
+
for (const k of approverKeys) if (typeof k === 'string' && k) out.add(k);
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
if (typeof approverKeys === 'object') {
|
|
269
|
+
for (const entry of Object.values(approverKeys)) {
|
|
270
|
+
if (typeof entry === 'string' && entry) { out.add(entry); continue; }
|
|
271
|
+
const pk = entry && typeof entry === 'object' ? entry.public_key : null;
|
|
272
|
+
if (typeof pk === 'string' && pk) out.add(pk);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
219
278
|
/** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
|
|
220
279
|
function isQuorumEvidence(q) {
|
|
221
280
|
return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
|
|
@@ -238,8 +297,16 @@ function isSignoffEvidence(s) {
|
|
|
238
297
|
* @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
|
|
239
298
|
* if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
|
|
240
299
|
* revoked) at its issuance time.
|
|
300
|
+
* @param {object} [opts.approverKeys] PINNED approver keys ({ keyId: { public_key, key_class } }).
|
|
301
|
+
* Used both for the pinned assurance-proof path and to authorize receipt-embedded
|
|
302
|
+
* approver keys under the self-contained embedded-evidence path.
|
|
303
|
+
* @param {boolean} [opts.allowEmbeddedApproverKeys=false] opt into the self-contained
|
|
304
|
+
* embedded-evidence mode: when true, a class_a/quorum tier may be credited from an
|
|
305
|
+
* approver key carried INSIDE the receipt even if that key is not pinned. DEFAULT OFF
|
|
306
|
+
* — with no pinned approverKeys and no opt-in, embedded evidence verifies but does not
|
|
307
|
+
* elevate above 'software' (prevents VERIFIED collapsing into ACCEPTED / trust-laundering).
|
|
241
308
|
*/
|
|
242
|
-
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null, requiredAdmissibilityProfile = null } = {}) {
|
|
309
|
+
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null, requiredAdmissibilityProfile = null, allowEmbeddedApproverKeys = false } = {}) {
|
|
243
310
|
// Production key custody: a registry (rotation + revocation) supersedes a flat
|
|
244
311
|
// trustedKeys list. A flat list is coerced to an always-valid registry, so
|
|
245
312
|
// existing callers are unchanged.
|
|
@@ -367,6 +434,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
367
434
|
});
|
|
368
435
|
const tierResult = receiptAssuranceTier(receipt, {
|
|
369
436
|
rpId, detail: true, approverKeys: approver_keys || approverKeys, verifyAssurance,
|
|
437
|
+
// Trust-laundering guard: a receipt-embedded approver key does NOT elevate
|
|
438
|
+
// the tier unless it is in the pinned approverKeys set, or the operator
|
|
439
|
+
// explicitly opted into the self-contained embedded-evidence mode. DEFAULT OFF.
|
|
440
|
+
allowEmbeddedApproverKeys,
|
|
370
441
|
});
|
|
371
442
|
// Take the strongest tier either path proves.
|
|
372
443
|
const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
|
|
@@ -502,8 +573,11 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
502
573
|
/**
|
|
503
574
|
* Recommended end-to-end path. Reserves the receipt, runs the side effect,
|
|
504
575
|
* commits one-time consumption only after success, and records execution.
|
|
505
|
-
*
|
|
506
|
-
*
|
|
576
|
+
* Once the executor is invoked, an exception is an INDETERMINATE outcome: the
|
|
577
|
+
* external effect may have happened before its response was lost. The receipt
|
|
578
|
+
* is therefore committed (or left reserved if the store is unavailable),
|
|
579
|
+
* never released automatically. Callers that need retries must make the
|
|
580
|
+
* downstream effect idempotent under the receipt id and reconcile its result.
|
|
507
581
|
*/
|
|
508
582
|
async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
|
|
509
583
|
if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
|
|
@@ -512,23 +586,49 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
512
586
|
return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
|
|
513
587
|
}
|
|
514
588
|
const receiptId = authorization.evidence?.receipt_id;
|
|
515
|
-
let
|
|
589
|
+
let phase = 'reserved';
|
|
590
|
+
let consumptionCommitted = false;
|
|
516
591
|
try {
|
|
592
|
+
phase = 'effect_attempted';
|
|
517
593
|
const result = await fn(authorization);
|
|
518
|
-
|
|
594
|
+
phase = 'effect_returned';
|
|
519
595
|
if (typeof consumption.commit === 'function') await consumption.commit(receiptId);
|
|
596
|
+
consumptionCommitted = true;
|
|
597
|
+
phase = 'consumed';
|
|
520
598
|
if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
|
|
599
|
+
phase = 'recording_execution';
|
|
521
600
|
const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
|
|
522
601
|
return { ok: true, result, authorization, execution, packet: reliancePacket({ authorization, execution }) };
|
|
523
602
|
} catch (e) {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
603
|
+
// An exception after invoking fn() cannot establish that no external
|
|
604
|
+
// effect occurred. Burn the approval if possible; if storage is down, the
|
|
605
|
+
// ownership-fenced reservation remains and still blocks replay.
|
|
606
|
+
let consumptionError = null;
|
|
607
|
+
if (!consumptionCommitted && phase !== 'reserved' && typeof consumption.commit === 'function') {
|
|
608
|
+
try {
|
|
609
|
+
await consumption.commit(receiptId);
|
|
610
|
+
consumptionCommitted = true;
|
|
611
|
+
} catch (commitError) {
|
|
612
|
+
consumptionError = commitError;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (opts.recordExecution !== false && phase !== 'recording_execution') {
|
|
616
|
+
try {
|
|
617
|
+
await recordExecution({
|
|
618
|
+
authorization,
|
|
619
|
+
outcome: 'indeterminate',
|
|
620
|
+
// Exception text frequently contains provider payloads, record IDs,
|
|
621
|
+
// or secrets. The caller still receives the original exception;
|
|
622
|
+
// the portable evidence record carries only the closed outcome.
|
|
623
|
+
detail: { code: 'effect_attempted_outcome_unknown' },
|
|
624
|
+
observedAction,
|
|
625
|
+
});
|
|
626
|
+
} catch (recordError) {
|
|
627
|
+
if (!consumptionError) consumptionError = recordError;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (consumptionError && e && typeof e === 'object') {
|
|
631
|
+
e.consumption_error = String(consumptionError?.message ?? consumptionError);
|
|
532
632
|
}
|
|
533
633
|
throw e;
|
|
534
634
|
}
|
|
@@ -684,6 +784,8 @@ export default {
|
|
|
684
784
|
ADMISSIBILITY_VERDICTS,
|
|
685
785
|
MemoryConsumptionStore,
|
|
686
786
|
createEvidenceLog,
|
|
787
|
+
createAtomicEvidenceLog,
|
|
788
|
+
createMemoryAtomicEvidenceBackend,
|
|
687
789
|
ASSURANCE_TIERS,
|
|
688
790
|
DEFAULT_GATE_MANIFEST,
|
|
689
791
|
HIGH_RISK_ACTION_PACKS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emilia-protocol/gate",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/emiliaprotocol/emilia-protocol.git",
|
|
7
|
+
"directory": "packages/gate"
|
|
8
|
+
},
|
|
4
9
|
"description": "EMILIA Gate \u2014 the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
|
|
5
10
|
"license": "Apache-2.0",
|
|
6
11
|
"type": "module",
|
|
@@ -32,19 +37,32 @@
|
|
|
32
37
|
"./reports/underwriter": "./reports/underwriter.js",
|
|
33
38
|
"./metrics": "./metrics.js",
|
|
34
39
|
"./breakglass": "./breakglass.js",
|
|
40
|
+
"./challenge-store": "./challenge-store.js",
|
|
35
41
|
"./store-postgres": "./store-postgres.js",
|
|
42
|
+
"./evidence": "./evidence.js",
|
|
36
43
|
"./reports/auditor-workpaper": "./reports/auditor-workpaper.js",
|
|
37
44
|
"./reports/reperform": "./reports/reperform.js",
|
|
38
|
-
"./reports/external-verification": "./reports/external-verification.js"
|
|
45
|
+
"./reports/external-verification": "./reports/external-verification.js",
|
|
46
|
+
"./reports/assurance-package": "./reports/assurance-package.js",
|
|
47
|
+
"./reliance-kernel": "./reliance-kernel.js",
|
|
48
|
+
"./aec-execution": "./aec-execution.js"
|
|
49
|
+
},
|
|
50
|
+
"bin": {
|
|
51
|
+
"ep-assure": "./ep-assure.mjs"
|
|
39
52
|
},
|
|
40
53
|
"files": [
|
|
41
54
|
"index.js",
|
|
42
55
|
"store.js",
|
|
56
|
+
"challenge-store.js",
|
|
43
57
|
"evidence.js",
|
|
44
58
|
"action-packs.js",
|
|
45
59
|
"action-control-manifest.js",
|
|
46
60
|
"execution-binding.js",
|
|
47
61
|
"reliance-packet.js",
|
|
62
|
+
"reliance-kernel.js",
|
|
63
|
+
"aec-execution.js",
|
|
64
|
+
"reports/assurance-package.js",
|
|
65
|
+
"ep-assure.mjs",
|
|
48
66
|
"key-registry.js",
|
|
49
67
|
"retention.js",
|
|
50
68
|
"eg1-conformance.js",
|