@emilia-protocol/gate 0.9.3 → 0.9.5
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 +315 -0
- package/challenge-store.js +70 -0
- package/deploy/helm/README.md +1 -1
- package/deploy/helm/emilia-gate/Chart.yaml +1 -1
- package/enterprise.js +4 -1
- package/ep-assure.mjs +65 -0
- package/evidence.js +273 -4
- package/index.js +47 -15
- package/package.json +21 -3
- 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/README.md
CHANGED
|
@@ -83,9 +83,12 @@ passes. A bare `@emilia-protocol/require-receipt` gate binds the action type/tar
|
|
|
83
83
|
this package when parameter drift (amount, beneficiary, commit, role, …) must be caught.
|
|
84
84
|
|
|
85
85
|
Prefer `gate.run(...)` for mutations: it reserves the receipt, runs the side effect, commits
|
|
86
|
-
one-time consumption
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
one-time consumption after success, and emits the execution receipt + reliance packet. Once the
|
|
87
|
+
executor is invoked, a thrown error is an **indeterminate effect**, not proof that nothing happened:
|
|
88
|
+
the approval is burned (or its no-TTL reservation remains frozen if the store is unavailable) so a
|
|
89
|
+
blind retry cannot duplicate the side effect. Retryable integrations should make the downstream
|
|
90
|
+
operation idempotent under `receipt_id` and reconcile the result. Use lower-level `gate.check(...)`
|
|
91
|
+
only when your framework has to separate authorization from execution.
|
|
89
92
|
|
|
90
93
|
Use your own manifest when you need custom policy:
|
|
91
94
|
|
|
@@ -223,8 +226,10 @@ challenge. The Gate composes that and adds the three things a firewall needs:
|
|
|
223
226
|
verifier in `@emilia-protocol/verify`.
|
|
224
227
|
- **One-time consumption** — a receipt authorizes one action, once. Replays are refused
|
|
225
228
|
(`replay_refused`). Default store is in-memory; swap in Redis/DB for a fleet.
|
|
226
|
-
- **Evidence log** —
|
|
227
|
-
|
|
229
|
+
- **Evidence log** — the local logger hash-chains decisions and detects alteration when given its
|
|
230
|
+
complete process history. It is not a fleet ledger: a sink cannot prevent restart-from-genesis or
|
|
231
|
+
cross-replica forks. Safety-critical deployments use `createAtomicEvidenceLog()` over a durable
|
|
232
|
+
backend whose compare-and-append transaction advances one shared head across replicas.
|
|
228
233
|
- **Execution-field binding** — for high-risk packs, the signed claim must match the executor's
|
|
229
234
|
observed mutation fields (`amount_usd`, `commit_sha`, `principal_id`, `record_id`, etc.). This
|
|
230
235
|
closes "approved harmless X, executed dangerous Y."
|
|
@@ -235,6 +240,20 @@ challenge. The Gate composes that and adds the three things a firewall needs:
|
|
|
235
240
|
|
|
236
241
|
The three things a serious buyer (CISO, auditor, insurer) asks after the demo:
|
|
237
242
|
|
|
243
|
+
**AEC execution custody.** `createAECExecutionGate()` requires a relying-party requirement,
|
|
244
|
+
executor-owned action, explicit human floor, and constructor-pinned custom verifier and key
|
|
245
|
+
registries. Transaction input may carry evidence, but never verifier code, trust keys, or human
|
|
246
|
+
acceptance profiles; attempts to do so are refused before verification. Production mode additionally refuses an expiring
|
|
247
|
+
consumption store or a process-local evidence logger. It consumes
|
|
248
|
+
`aec:action:<canonical-action-digest>` before the effect, passes the effect a frozen pre-await action
|
|
249
|
+
snapshot, and conservatively burns or freezes the action after an indeterminate result. Every
|
|
250
|
+
otherwise identical intended effect therefore needs a unique action-instance nonce inside the
|
|
251
|
+
signed action. Use `createAtomicEvidenceLog()` from `@emilia-protocol/gate/evidence`; its backend
|
|
252
|
+
must atomically compare and append against one durable shared head. The gate independently
|
|
253
|
+
recomputes every logger acknowledgment and requires its entry bytes to equal the requested
|
|
254
|
+
decision; the atomic logger also requires readback to equal the exact submitted sequence and
|
|
255
|
+
predecessor.
|
|
256
|
+
|
|
238
257
|
**Issuer key rotation + revocation.** A flat `trustedKeys` list can't revoke a leaked key
|
|
239
258
|
or rotate without downtime. A key registry can — a receipt is verified only against keys
|
|
240
259
|
valid (and not revoked) at its issuance time. Revocation is fail-closed and immediate.
|
|
@@ -251,15 +270,24 @@ registry.revoke('issuer-1'); // compromised — refused immediately, live, no re
|
|
|
251
270
|
```
|
|
252
271
|
|
|
253
272
|
**Fleet-safe replay defense.** The in-memory store is per-process. In production, back the
|
|
254
|
-
consumption store with a shared key-value store whose insert-if-absent
|
|
273
|
+
consumption store with a shared key-value store whose insert-if-absent, compare-and-set, and
|
|
274
|
+
conditional delete operations are atomic:
|
|
255
275
|
|
|
256
276
|
```js
|
|
257
277
|
import { createDurableConsumptionStore } from '@emilia-protocol/gate';
|
|
258
|
-
const store = createDurableConsumptionStore(redisBackend); //
|
|
278
|
+
const store = createDurableConsumptionStore(redisBackend); // addIfAbsent + compareAndSet + deleteIfValue + has
|
|
259
279
|
const gate = createGate({ manifest, keyRegistry, store });
|
|
260
280
|
// A receipt consumed on one pod cannot be replayed on another.
|
|
261
281
|
```
|
|
262
282
|
|
|
283
|
+
Reservations carry an opaque owner token and have no TTL. Only that owner may commit or release;
|
|
284
|
+
an abandoned reservation requires reconciliation because automatically reopening it after a crash
|
|
285
|
+
could repeat an effect whose response was lost. A TTL may apply only to committed rows.
|
|
286
|
+
The Postgres adapter rejects malformed or regressing clocks before expiry-bearing state changes.
|
|
287
|
+
The model-based fault gate runs 5,000 generated schedules across crash, lag, rollback, failover,
|
|
288
|
+
duplicate delivery, and before/after-linearization response loss; see
|
|
289
|
+
`security/CONSUMPTION_FAULT_STATUS.md`.
|
|
290
|
+
|
|
263
291
|
**Evidence retention.** Classify the evidence log into hot/cold/expired with legal hold, and
|
|
264
292
|
export the auditor/SIEM manifest (tied to the evidence head). `EP_AUDIT_HOT_DAYS` /
|
|
265
293
|
`EP_AUDIT_COLD_DAYS` set the horizons.
|
package/aec-execution.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Stateful execution boundary for EP-AEC.
|
|
4
|
+
*
|
|
5
|
+
* verifyAuthorizationChain is a pure evidence-composition decision. This
|
|
6
|
+
* wrapper adds the stateful properties an executor needs: independently bound
|
|
7
|
+
* action bytes, a mandatory human-assurance floor, atomic one-time reservation,
|
|
8
|
+
* tamper-evident decision records, and conservative crash semantics.
|
|
9
|
+
*/
|
|
10
|
+
import { createEvidenceLog, verifyEvidenceRecord } from './evidence.js';
|
|
11
|
+
import { MemoryConsumptionStore } from './store.js';
|
|
12
|
+
|
|
13
|
+
const { verifyAuthorizationChain } = await import('@emilia-protocol/verify/evidence-chain')
|
|
14
|
+
.catch(() => import('../verify/evidence-chain.js'));
|
|
15
|
+
|
|
16
|
+
const HUMAN_FLOORS = new Set(['class_a', 'quorum', 'class_a_or_quorum']);
|
|
17
|
+
const HEX_256 = /^[0-9a-f]{64}$/;
|
|
18
|
+
const COMPONENT_TYPE = /^[A-Za-z0-9_.:-]+$/;
|
|
19
|
+
const RESERVED_COMPONENT_TYPES = new Set(['ep-receipt', 'ep-quorum']);
|
|
20
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
21
|
+
|
|
22
|
+
function deepFreeze(value) {
|
|
23
|
+
if (!value || typeof value !== 'object') return value;
|
|
24
|
+
const stack = [value];
|
|
25
|
+
const seen = new WeakSet();
|
|
26
|
+
while (stack.length) {
|
|
27
|
+
const current = stack.pop();
|
|
28
|
+
if (!current || typeof current !== 'object' || seen.has(current)) continue;
|
|
29
|
+
seen.add(current);
|
|
30
|
+
for (const child of Object.values(current)) stack.push(child);
|
|
31
|
+
Object.freeze(current);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validLogRecord(record, atomicRequired, expectedEntry) {
|
|
37
|
+
return verifyEvidenceRecord(record, { atomicRequired, expectedEntry });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validComponent(result, type) {
|
|
41
|
+
return Array.isArray(result?.components)
|
|
42
|
+
&& result.components.some((component) => component.type === type && component.valid === true && component.bound === true);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function humanFloorSatisfied(result, floor) {
|
|
46
|
+
const classA = validComponent(result, 'ep-receipt');
|
|
47
|
+
const quorum = validComponent(result, 'ep-quorum');
|
|
48
|
+
if (floor === 'class_a') return classA;
|
|
49
|
+
if (floor === 'quorum') return quorum;
|
|
50
|
+
return classA || quorum;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function evidenceSatisfied(result) {
|
|
54
|
+
try {
|
|
55
|
+
if (!result || typeof result !== 'object') return false;
|
|
56
|
+
// New verifiers expose the evidence-layer term. Older supported 3.x
|
|
57
|
+
// verifiers expose the equivalent `allow` result only. If `satisfied` is
|
|
58
|
+
// present, it is authoritative so a conflicting compatibility alias can
|
|
59
|
+
// never upgrade a refusal.
|
|
60
|
+
return own(result, 'satisfied') ? result.satisfied === true : result.allow === true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function consumptionKey(result) {
|
|
67
|
+
// Consume the executor-owned action instance, not a presenter-selected
|
|
68
|
+
// component identifier. Otherwise an invalid decoy component or an alternate
|
|
69
|
+
// valid human proof can create a fresh key for the same physical effect.
|
|
70
|
+
// Repeated intended effects therefore need a unique nonce/id inside the
|
|
71
|
+
// canonical action before authorization is collected.
|
|
72
|
+
return HEX_256.test(result?.action_digest)
|
|
73
|
+
? `aec:action:${result.action_digest}`
|
|
74
|
+
: null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function instant(now) {
|
|
78
|
+
try {
|
|
79
|
+
const value = typeof now === 'function' ? now() : now;
|
|
80
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
81
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {object} config
|
|
89
|
+
* @param {string} config.requirement relying-party AEC requirement
|
|
90
|
+
* @param {object} config.policiesByType relying-party human acceptance profiles
|
|
91
|
+
* @param {object} [config.verifiers] relying-party-pinned custom component verifiers
|
|
92
|
+
* @param {object} [config.keysByType] relying-party-pinned custom verifier keys
|
|
93
|
+
* @param {'class_a'|'quorum'|'class_a_or_quorum'} config.humanFloor
|
|
94
|
+
* @param {object} [config.store] ownership-fenced consumption store
|
|
95
|
+
* @param {object} [config.log] tamper-evident evidence log
|
|
96
|
+
* @param {boolean} [config.allowEphemeralState=false] test/demo opt-in only
|
|
97
|
+
* @param {Function|number|Date} [config.now=Date.now]
|
|
98
|
+
*/
|
|
99
|
+
export function createAECExecutionGate({
|
|
100
|
+
requirement,
|
|
101
|
+
policiesByType,
|
|
102
|
+
verifiers = {},
|
|
103
|
+
keysByType = {},
|
|
104
|
+
humanFloor,
|
|
105
|
+
store,
|
|
106
|
+
log,
|
|
107
|
+
allowEphemeralState = false,
|
|
108
|
+
now = Date.now,
|
|
109
|
+
} = {}) {
|
|
110
|
+
if (typeof requirement !== 'string' || !requirement.trim()) {
|
|
111
|
+
throw new Error('AEC execution gate requires a relying-party requirement');
|
|
112
|
+
}
|
|
113
|
+
if (!policiesByType || typeof policiesByType !== 'object' || Array.isArray(policiesByType)) {
|
|
114
|
+
throw new Error('AEC execution gate requires relying-party policiesByType');
|
|
115
|
+
}
|
|
116
|
+
let pinnedPolicies;
|
|
117
|
+
try { pinnedPolicies = deepFreeze(structuredClone(policiesByType)); }
|
|
118
|
+
catch { throw new Error('AEC execution gate policiesByType must be cloneable canonical data'); }
|
|
119
|
+
if (!verifiers || typeof verifiers !== 'object' || Array.isArray(verifiers)) {
|
|
120
|
+
throw new Error('AEC execution gate verifiers must be a relying-party-owned object');
|
|
121
|
+
}
|
|
122
|
+
const pinnedVerifiers = Object.create(null);
|
|
123
|
+
try {
|
|
124
|
+
for (const [type, verifier] of Object.entries(verifiers)) {
|
|
125
|
+
if (!COMPONENT_TYPE.test(type) || type.length > 128 || RESERVED_COMPONENT_TYPES.has(type)
|
|
126
|
+
|| typeof verifier !== 'function') {
|
|
127
|
+
throw new Error('invalid verifier registry member');
|
|
128
|
+
}
|
|
129
|
+
pinnedVerifiers[type] = verifier;
|
|
130
|
+
}
|
|
131
|
+
Object.freeze(pinnedVerifiers);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error('AEC execution gate verifiers must contain only named custom verifier functions');
|
|
134
|
+
}
|
|
135
|
+
if (!keysByType || typeof keysByType !== 'object' || Array.isArray(keysByType)) {
|
|
136
|
+
throw new Error('AEC execution gate keysByType must be a relying-party-owned object');
|
|
137
|
+
}
|
|
138
|
+
let pinnedKeysByType;
|
|
139
|
+
try { pinnedKeysByType = deepFreeze(structuredClone(keysByType)); }
|
|
140
|
+
catch { throw new Error('AEC execution gate keysByType must be cloneable canonical data'); }
|
|
141
|
+
if (!HUMAN_FLOORS.has(humanFloor)) {
|
|
142
|
+
throw new Error('AEC execution gate requires humanFloor class_a, quorum, or class_a_or_quorum');
|
|
143
|
+
}
|
|
144
|
+
if (!store && !allowEphemeralState) {
|
|
145
|
+
throw new Error('AEC execution gate requires a durable consumption store');
|
|
146
|
+
}
|
|
147
|
+
if (!log && !allowEphemeralState) {
|
|
148
|
+
throw new Error('AEC execution gate requires a durable strict evidence log');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const consumption = store || new MemoryConsumptionStore();
|
|
152
|
+
const evidence = log || createEvidenceLog({ strict: true });
|
|
153
|
+
if (!allowEphemeralState && (consumption.durable !== true || consumption.ownershipFenced !== true
|
|
154
|
+
|| consumption.permanentConsumption !== true)) {
|
|
155
|
+
throw new Error('AEC execution gate requires a capability-marked, ownership-fenced durable store with non-expiring committed keys');
|
|
156
|
+
}
|
|
157
|
+
if (!allowEphemeralState && (evidence.durable !== true || evidence.strict !== true
|
|
158
|
+
|| evidence.forkAware !== true || evidence.atomicAppend !== true)) {
|
|
159
|
+
throw new Error('AEC execution gate requires a durable strict evidence log with atomic shared-head append and fork detection');
|
|
160
|
+
}
|
|
161
|
+
for (const method of ['reserve', 'commit']) {
|
|
162
|
+
if (typeof consumption?.[method] !== 'function') {
|
|
163
|
+
throw new Error(`AEC execution gate consumption store requires ${method}()`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (typeof evidence?.record !== 'function') {
|
|
167
|
+
throw new Error('AEC execution gate evidence log requires record()');
|
|
168
|
+
}
|
|
169
|
+
// Capture the methods that passed construction checks. Callers may retain the
|
|
170
|
+
// objects for observability, but replacing a method later must not rewrite the
|
|
171
|
+
// gate's replay or evidence semantics.
|
|
172
|
+
const reserveConsumption = consumption.reserve.bind(consumption);
|
|
173
|
+
const commitConsumption = consumption.commit.bind(consumption);
|
|
174
|
+
const releaseConsumption = typeof consumption.release === 'function'
|
|
175
|
+
? consumption.release.bind(consumption) : null;
|
|
176
|
+
const recordEvidence = evidence.record.bind(evidence);
|
|
177
|
+
|
|
178
|
+
async function deny(reason, result = null, extra = {}) {
|
|
179
|
+
let decision = null;
|
|
180
|
+
try {
|
|
181
|
+
const entry = {
|
|
182
|
+
type: 'aec.execution.decision',
|
|
183
|
+
at: instant(now),
|
|
184
|
+
allow: false,
|
|
185
|
+
reason,
|
|
186
|
+
action_digest: result?.action_digest ?? null,
|
|
187
|
+
requirement,
|
|
188
|
+
human_floor: humanFloor,
|
|
189
|
+
...extra,
|
|
190
|
+
};
|
|
191
|
+
decision = await recordEvidence(entry);
|
|
192
|
+
if (!validLogRecord(decision, !allowEphemeralState, entry)) throw new Error('malformed evidence record');
|
|
193
|
+
} catch {
|
|
194
|
+
return { ok: false, allow: false, reason: 'evidence_log_failed', result, decision: null };
|
|
195
|
+
}
|
|
196
|
+
return { ok: false, allow: false, reason, result, decision };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function run(request = {}, effect) {
|
|
200
|
+
if (typeof effect !== 'function') throw new Error('AEC execution gate run() requires an effect function');
|
|
201
|
+
let chain;
|
|
202
|
+
let expectedAction;
|
|
203
|
+
try {
|
|
204
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
205
|
+
return deny('invalid_execution_request');
|
|
206
|
+
}
|
|
207
|
+
// Trust configuration belongs to the relying party at gate construction.
|
|
208
|
+
// Accepting verifier code or trust keys beside presenter evidence lets the
|
|
209
|
+
// presenter define the proof that its own evidence must pass.
|
|
210
|
+
if (own(request, 'verifiers') || own(request, 'keysByType') || own(request, 'policiesByType')) {
|
|
211
|
+
return deny('runtime_trust_configuration_refused');
|
|
212
|
+
}
|
|
213
|
+
chain = request.chain;
|
|
214
|
+
expectedAction = request.expectedAction;
|
|
215
|
+
} catch {
|
|
216
|
+
return deny('invalid_execution_request');
|
|
217
|
+
}
|
|
218
|
+
const verificationTime = instant(now);
|
|
219
|
+
if (!verificationTime) return deny('invalid_verification_time');
|
|
220
|
+
let actionSnapshot;
|
|
221
|
+
try { actionSnapshot = deepFreeze(structuredClone(expectedAction)); }
|
|
222
|
+
catch { return deny('invalid_expected_action'); }
|
|
223
|
+
|
|
224
|
+
const result = verifyAuthorizationChain(chain, {
|
|
225
|
+
verifiers: pinnedVerifiers,
|
|
226
|
+
keysByType: pinnedKeysByType,
|
|
227
|
+
policiesByType: pinnedPolicies,
|
|
228
|
+
requirement,
|
|
229
|
+
expectedAction: actionSnapshot,
|
|
230
|
+
verificationTime,
|
|
231
|
+
});
|
|
232
|
+
if (!evidenceSatisfied(result)) return deny('aec_refused', result, { reasons: result.reasons });
|
|
233
|
+
if (!humanFloorSatisfied(result, humanFloor)) return deny('human_floor_unsatisfied', result);
|
|
234
|
+
|
|
235
|
+
const key = consumptionKey(result);
|
|
236
|
+
if (!key) return deny('missing_stable_consumption_key', result);
|
|
237
|
+
|
|
238
|
+
let reserved;
|
|
239
|
+
try { reserved = (await reserveConsumption(key)) === true; }
|
|
240
|
+
catch { return deny('consumption_store_unavailable', result, { consumption_key: key }); }
|
|
241
|
+
if (!reserved) return deny('replay_refused', result, { consumption_key: key });
|
|
242
|
+
|
|
243
|
+
let authorization;
|
|
244
|
+
try {
|
|
245
|
+
const entry = {
|
|
246
|
+
type: 'aec.execution.decision',
|
|
247
|
+
at: verificationTime,
|
|
248
|
+
allow: true,
|
|
249
|
+
reason: 'allow',
|
|
250
|
+
action_digest: result.action_digest,
|
|
251
|
+
requirement,
|
|
252
|
+
human_floor: humanFloor,
|
|
253
|
+
consumption_key: key,
|
|
254
|
+
};
|
|
255
|
+
authorization = await recordEvidence(entry);
|
|
256
|
+
if (!validLogRecord(authorization, !allowEphemeralState, entry)) throw new Error('malformed evidence record');
|
|
257
|
+
} catch {
|
|
258
|
+
try { if (releaseConsumption) await releaseConsumption(key); } catch { /* frozen is safe */ }
|
|
259
|
+
return { ok: false, allow: false, reason: 'evidence_log_failed', result, decision: null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let effectStarted = false;
|
|
263
|
+
let committed = false;
|
|
264
|
+
try {
|
|
265
|
+
effectStarted = true;
|
|
266
|
+
const value = await effect({ action: actionSnapshot, result, authorization });
|
|
267
|
+
if ((await commitConsumption(key)) !== true) throw new Error('consumption_store_commit_refused');
|
|
268
|
+
committed = true;
|
|
269
|
+
const executionEntry = {
|
|
270
|
+
type: 'aec.execution.outcome',
|
|
271
|
+
at: instant(now),
|
|
272
|
+
outcome: 'executed',
|
|
273
|
+
authorizes_decision: authorization.hash,
|
|
274
|
+
action_digest: result.action_digest,
|
|
275
|
+
consumption_key: key,
|
|
276
|
+
};
|
|
277
|
+
const execution = await recordEvidence(executionEntry);
|
|
278
|
+
if (!validLogRecord(execution, !allowEphemeralState, executionEntry)) throw new Error('malformed evidence record');
|
|
279
|
+
return { ok: true, allow: true, value, result, authorization, execution };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (effectStarted && !committed) {
|
|
282
|
+
try { committed = (await commitConsumption(key)) === true; } catch { /* reservation remains fail-closed */ }
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const indeterminateEntry = {
|
|
286
|
+
type: 'aec.execution.outcome',
|
|
287
|
+
at: instant(now),
|
|
288
|
+
outcome: 'indeterminate',
|
|
289
|
+
authorizes_decision: authorization.hash,
|
|
290
|
+
action_digest: result.action_digest,
|
|
291
|
+
consumption_key: key,
|
|
292
|
+
};
|
|
293
|
+
const indeterminate = await recordEvidence(indeterminateEntry);
|
|
294
|
+
if (!validLogRecord(indeterminate, !allowEphemeralState, indeterminateEntry)) {
|
|
295
|
+
throw new Error('malformed evidence record');
|
|
296
|
+
}
|
|
297
|
+
} catch { /* the original failure remains primary; replay is still blocked */ }
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { run, evidence, store: consumption };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export const __aecExecutionSecurityInternals = Object.freeze({
|
|
306
|
+
deepFreeze,
|
|
307
|
+
validLogRecord,
|
|
308
|
+
validComponent,
|
|
309
|
+
humanFloorSatisfied,
|
|
310
|
+
evidenceSatisfied,
|
|
311
|
+
consumptionKey,
|
|
312
|
+
instant,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
export default { createAECExecutionGate };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Durable, body-bound lifecycle store for AE-CHALLENGE-v1.
|
|
4
|
+
*
|
|
5
|
+
* Registration is atomic insert-if-absent. Consumption is an atomic transition
|
|
6
|
+
* from the digest of the exact registered body to a consumed state carrying
|
|
7
|
+
* that same digest. A presenter that changes action, profile, expiry, or any
|
|
8
|
+
* other challenge member cannot consume the original registration even when it
|
|
9
|
+
* reuses the challenge id and nonce.
|
|
10
|
+
*/
|
|
11
|
+
import { hashCanonical } from './execution-binding.js';
|
|
12
|
+
|
|
13
|
+
export const DURABLE_CHALLENGE_STORE_VERSION = 'EP-DURABLE-CHALLENGE-STORE-v1';
|
|
14
|
+
const OPEN_PREFIX = 'challenge-open:v1:';
|
|
15
|
+
const CONSUMED_PREFIX = 'challenge-consumed:v1:';
|
|
16
|
+
|
|
17
|
+
function assertChallenge(challenge) {
|
|
18
|
+
if (!challenge || typeof challenge !== 'object' || Array.isArray(challenge)) {
|
|
19
|
+
throw new Error('challenge must be an object');
|
|
20
|
+
}
|
|
21
|
+
if (challenge['@version'] !== 'AE-CHALLENGE-v1') throw new Error('unsupported challenge version');
|
|
22
|
+
if (typeof challenge.challenge_id !== 'string' || !challenge.challenge_id.trim()) throw new Error('challenge_id is required');
|
|
23
|
+
if (typeof challenge.nonce !== 'string' || !challenge.nonce.trim()) throw new Error('challenge nonce is required');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function challengeStorageKey(challenge) {
|
|
27
|
+
assertChallenge(challenge);
|
|
28
|
+
return `ae-challenge:${hashCanonical({ challenge_id: challenge.challenge_id, nonce: challenge.nonce })}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function challengeBodyDigest(challenge) {
|
|
32
|
+
assertChallenge(challenge);
|
|
33
|
+
return hashCanonical(challenge);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDurableChallengeStore(backend) {
|
|
37
|
+
for (const method of ['addIfAbsent', 'compareAndSet', 'has']) {
|
|
38
|
+
if (typeof backend?.[method] !== 'function') {
|
|
39
|
+
throw new Error(`createDurableChallengeStore: backend must implement atomic async ${method}()`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
durable: backend.durable === true,
|
|
45
|
+
atomicRegistration: true,
|
|
46
|
+
bodyBound: true,
|
|
47
|
+
permanentConsumption: true,
|
|
48
|
+
async register(challenge) {
|
|
49
|
+
const key = challengeStorageKey(challenge);
|
|
50
|
+
const digest = challengeBodyDigest(challenge);
|
|
51
|
+
return (await backend.addIfAbsent(key, `${OPEN_PREFIX}${digest}`)) === true;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async consume(challenge) {
|
|
55
|
+
const key = challengeStorageKey(challenge);
|
|
56
|
+
const digest = challengeBodyDigest(challenge);
|
|
57
|
+
return (await backend.compareAndSet(
|
|
58
|
+
key,
|
|
59
|
+
`${OPEN_PREFIX}${digest}`,
|
|
60
|
+
`${CONSUMED_PREFIX}${digest}`,
|
|
61
|
+
)) === true;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async has(challenge) {
|
|
65
|
+
return (await backend.has(challengeStorageKey(challenge))) === true;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default { createDurableChallengeStore, challengeStorageKey, challengeBodyDigest, DURABLE_CHALLENGE_STORE_VERSION };
|
package/deploy/helm/README.md
CHANGED
|
@@ -50,7 +50,7 @@ kubectl -n emilia create secret generic emilia-gate-issuer-keys \
|
|
|
50
50
|
helm install gate ./emilia-gate \
|
|
51
51
|
--namespace emilia --create-namespace \
|
|
52
52
|
--set image.repository=YOUR_REGISTRY/emilia-gate \
|
|
53
|
-
--set image.tag=0.9.
|
|
53
|
+
--set image.tag=0.9.5 \
|
|
54
54
|
--set issuerKeys.existingSecret=emilia-gate-issuer-keys
|
|
55
55
|
|
|
56
56
|
# 3. (Optional) expose /metrics + ServiceMonitor stub (needs Prometheus Operator).
|
|
@@ -19,7 +19,7 @@ type: application
|
|
|
19
19
|
version: 0.1.0
|
|
20
20
|
# Version of @emilia-protocol/gate this chart tracks; used as the default
|
|
21
21
|
# image tag when .Values.image.tag is empty.
|
|
22
|
-
appVersion: "0.9.
|
|
22
|
+
appVersion: "0.9.5"
|
|
23
23
|
kubeVersion: ">=1.23.0-0"
|
|
24
24
|
home: https://emiliaprotocol.ai
|
|
25
25
|
sources:
|
package/enterprise.js
CHANGED
|
@@ -73,7 +73,10 @@ export function mintEntitlement(privateKey, {
|
|
|
73
73
|
if (toMs(not_before) == null) throw new Error('entitlement: not_before is required (ISO or ms)');
|
|
74
74
|
if (toMs(expires_at) == null) throw new Error('entitlement: expires_at is required (ISO or ms)');
|
|
75
75
|
if (!kid || typeof kid !== 'string') throw new Error('entitlement: kid is required');
|
|
76
|
-
|
|
76
|
+
// Snapshot features/limits into the signed payload: embedding the caller's live
|
|
77
|
+
// array/object would let a licensing service mutate them after minting and
|
|
78
|
+
// diverge the entitlement from its signature.
|
|
79
|
+
const payload = { org, tier, features: structuredClone(features), limits: structuredClone(limits), not_before, expires_at, kid };
|
|
77
80
|
const value = crypto.sign(null, Buffer.from(canonical(payload), 'utf8'), privateKey).toString('base64url');
|
|
78
81
|
return { '@version': ENTITLEMENT_VERSION, payload, signature: { algorithm: 'Ed25519', value } };
|
|
79
82
|
}
|
package/ep-assure.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* ep-assure — re-perform an EMILIA reliance assurance package and emit an auditor
|
|
5
|
+
* workpaper. The independent-assurer CLI: point it at a package (or raw decisions
|
|
6
|
+
* + a pinned profile) and auditor-supplied keys, and it recomputes every reliance
|
|
7
|
+
* verdict offline, detects drift against the runtime's stated verdicts, maps each
|
|
8
|
+
* to a control objective, and prints the workpaper. Exit code is non-zero when it
|
|
9
|
+
* finds a decision that RELIED ON INADMISSIBLE evidence (claimed rely, recomputes
|
|
10
|
+
* to a refusal), so it drops into CI/audit pipelines.
|
|
11
|
+
*
|
|
12
|
+
* node packages/gate/ep-assure.mjs <input.json> [--json] [--strict]
|
|
13
|
+
*
|
|
14
|
+
* input.json is one of:
|
|
15
|
+
* { "package": <EP-ASSURANCE-PACKAGE-v1>, "keys": {...}, "now": <iso|ms> }
|
|
16
|
+
* { "decisions": [...], "profile": <EP-RELIANCE-PROFILE-v1>, "keys": {...}, "now": <iso|ms> }
|
|
17
|
+
*
|
|
18
|
+
* keys (auditor-pinned, out of band): { approverKeys, logPublicKey, rpId, revokerKeys }
|
|
19
|
+
* --json print the full EP-ASSURANCE-REPERFORMANCE-v1 document instead of text
|
|
20
|
+
* --strict exit non-zero if ANY drift is found (default: only inadmissible-reliance drift)
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { buildAssurancePackage, reperformAssurancePackage, renderAssuranceWorkpaper } from './reports/assurance-package.js';
|
|
24
|
+
|
|
25
|
+
function fail(msg) { process.stderr.write(`ep-assure: ${msg}\n`); process.exit(2); }
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const flags = new Set(args.filter((a) => a.startsWith('--')));
|
|
29
|
+
const path = args.find((a) => !a.startsWith('--'));
|
|
30
|
+
if (!path) fail('usage: ep-assure <input.json> [--json] [--strict]');
|
|
31
|
+
|
|
32
|
+
let input;
|
|
33
|
+
try { input = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { fail(`cannot read ${path}: ${e.message}`); }
|
|
34
|
+
|
|
35
|
+
const keys = input.keys || {};
|
|
36
|
+
const now = input.now != null ? (typeof input.now === 'string' ? Date.parse(input.now) : input.now) : 0;
|
|
37
|
+
|
|
38
|
+
let pkg = input.package;
|
|
39
|
+
if (!pkg) {
|
|
40
|
+
if (!Array.isArray(input.decisions)) fail('input needs a `package` or a `decisions` array');
|
|
41
|
+
pkg = buildAssurancePackage(input.decisions, { profile: input.profile, organization: input.organization || null, now });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let doc;
|
|
45
|
+
try {
|
|
46
|
+
doc = reperformAssurancePackage(pkg, {
|
|
47
|
+
approverKeys: keys.approverKeys || {}, logPublicKey: keys.logPublicKey || null,
|
|
48
|
+
rpId: keys.rpId || null, revokerKeys: keys.revokerKeys || {}, now,
|
|
49
|
+
});
|
|
50
|
+
} catch (e) { fail(e.message); }
|
|
51
|
+
|
|
52
|
+
if (flags.has('--json')) {
|
|
53
|
+
process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
|
|
54
|
+
} else {
|
|
55
|
+
process.stdout.write(renderAssuranceWorkpaper(doc) + '\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const inadmissibleReliance = doc.population.relied_on_inadmissible_evidence;
|
|
59
|
+
const anyDrift = doc.population.drift;
|
|
60
|
+
const bad = flags.has('--strict') ? anyDrift : inadmissibleReliance;
|
|
61
|
+
if (bad > 0) {
|
|
62
|
+
process.stderr.write(`ep-assure: ${bad} finding(s) — ${flags.has('--strict') ? 'drift' : 'reliance on inadmissible evidence'} detected\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
process.exit(0);
|