@emilia-protocol/gate 0.1.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +218 -13
- package/action-packs.js +141 -0
- package/adapters/_kit.js +63 -0
- package/adapters/aws.js +98 -0
- package/adapters/gcp.js +71 -0
- package/adapters/github-demo.mjs +57 -0
- package/adapters/github.js +110 -0
- package/adapters/k8s.js +71 -0
- package/adapters/stripe.js +82 -0
- package/adapters/supabase.js +100 -0
- package/adapters/terraform.js +60 -0
- package/custody-demo.mjs +41 -0
- package/demo.mjs +57 -0
- package/eg1-conformance.js +206 -0
- package/eg1.mjs +40 -0
- package/execution-binding.js +98 -0
- package/index.js +184 -25
- package/key-registry.js +103 -0
- package/mcp.js +100 -0
- package/package.json +20 -5
- package/reliance-packet.js +65 -0
- package/retention.js +85 -0
- package/store.js +75 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EG-1 Conformance — the binary artifact that turns "we adopted EMILIA Gate"
|
|
4
|
+
* from a claim into a test result.
|
|
5
|
+
*
|
|
6
|
+
* The question EG-1 answers: *does your integration actually ENFORCE the gate,
|
|
7
|
+
* or are you only claiming it?* An integration earns EG-1 only if it
|
|
8
|
+
* demonstrably:
|
|
9
|
+
* 1. refuses a high-risk action with NO receipt (428);
|
|
10
|
+
* 2. refuses a software-tier receipt on a Class-A action;
|
|
11
|
+
* 3. refuses when the observed execution drifts from the authorized fields;
|
|
12
|
+
* 4. RUNS the action for a valid Class-A/quorum receipt;
|
|
13
|
+
* 5. refuses a replay of the same receipt;
|
|
14
|
+
* 6. refuses a tampered receipt;
|
|
15
|
+
* 7. emits an execution proof bound to the authorization decision;
|
|
16
|
+
* 8. produces a reliance packet whose verdict is "rely".
|
|
17
|
+
*
|
|
18
|
+
* This module is pure (no dependency on index.js, so no import cycle): it owns a
|
|
19
|
+
* throwaway issuer keypair, mints the scenario receipts, and drives any
|
|
20
|
+
* "subject" through the eight checks. A subject is an async `invoke` function
|
|
21
|
+
* representing one attempt at the guarded dangerous action:
|
|
22
|
+
*
|
|
23
|
+
* invoke({ receipt, observedAction }) -> {
|
|
24
|
+
* allowed: boolean, status: number, reason: string,
|
|
25
|
+
* // present only on an allowed run:
|
|
26
|
+
* decisionHash?: string, execution?: { authorizes_decision }, packet?: { verdict }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* For integrations built on @emilia-protocol/gate, `makeGateInvoke(gate, ...)`
|
|
30
|
+
* produces a conformant `invoke` from `gate.run()`. Custom integrations (an HTTP
|
|
31
|
+
* service, a different language) implement `invoke` themselves and configure
|
|
32
|
+
* their gate to trust `harness.publicKey` for the run.
|
|
33
|
+
*/
|
|
34
|
+
import crypto from 'node:crypto';
|
|
35
|
+
|
|
36
|
+
export const EG1_VERSION = 'EG-1';
|
|
37
|
+
|
|
38
|
+
// Same sorted-key canonical JSON the receipt signature is computed over.
|
|
39
|
+
const canon = (v) => (v == null ? JSON.stringify(v)
|
|
40
|
+
: Array.isArray(v) ? `[${v.map(canon).join(',')}]`
|
|
41
|
+
: typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',')}}`
|
|
42
|
+
: JSON.stringify(v));
|
|
43
|
+
|
|
44
|
+
// The default high-risk action EG-1 exercises: a Class-A money movement, which
|
|
45
|
+
// the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
|
|
46
|
+
export const EG1_DEFAULT_SELECTOR = Object.freeze({ protocol: 'mcp', tool: 'release_payment' });
|
|
47
|
+
export const EG1_DEFAULT_ACTION = Object.freeze({
|
|
48
|
+
action_type: 'payment.release',
|
|
49
|
+
amount_usd: 40000,
|
|
50
|
+
currency: 'USD',
|
|
51
|
+
payment_instruction_id: 'pi_eg1_40000',
|
|
52
|
+
beneficiary_account_hash: 'sha256:eg1-beneficiary',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const EG1_CHECKS = Object.freeze([
|
|
56
|
+
{ id: 'missing_receipt_refused', title: 'missing receipt → 428' },
|
|
57
|
+
{ id: 'software_on_classA_refused', title: 'software receipt on Class-A action → refused' },
|
|
58
|
+
{ id: 'execution_drift_refused', title: 'observed execution drift → refused' },
|
|
59
|
+
{ id: 'valid_classA_runs', title: 'valid Class-A/quorum receipt → runs' },
|
|
60
|
+
{ id: 'replay_refused', title: 'same receipt replay → refused' },
|
|
61
|
+
{ id: 'tampered_refused', title: 'tampered receipt → refused' },
|
|
62
|
+
{ id: 'execution_proof_binds', title: 'execution proof binds to authorization decision' },
|
|
63
|
+
{ id: 'reliance_packet_rely', title: 'reliance packet returns verdict "rely"' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create an EG-1 harness: a throwaway issuer key + a receipt minter for the
|
|
68
|
+
* scenarios. Configure the subject's gate to trust `publicKey` for the run.
|
|
69
|
+
*/
|
|
70
|
+
export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION, idPrefix = 'eg1' } = {}) {
|
|
71
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
72
|
+
const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
|
|
73
|
+
let counter = 0;
|
|
74
|
+
const nowMs = () => (typeof now === 'function' ? now() : now);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint a scenario receipt.
|
|
78
|
+
* @param {object} o
|
|
79
|
+
* @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software, 'allow_with_signoff'=Class-A
|
|
80
|
+
* @param {object} [o.quorum] a quorum block (signers/threshold) -> quorum tier
|
|
81
|
+
* @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
|
|
82
|
+
*/
|
|
83
|
+
function mint({ outcome = 'allow_with_signoff', quorum = null, tamper = null, extra = {} } = {}) {
|
|
84
|
+
const payload = {
|
|
85
|
+
receipt_id: `${idPrefix}_${++counter}`,
|
|
86
|
+
subject: 'agent:eg1-conformance',
|
|
87
|
+
issuer: 'ep:org:eg1',
|
|
88
|
+
created_at: new Date(nowMs()).toISOString(),
|
|
89
|
+
claim: {
|
|
90
|
+
...action,
|
|
91
|
+
outcome,
|
|
92
|
+
approver: 'ep:approver:eg1',
|
|
93
|
+
...(quorum ? { quorum } : {}),
|
|
94
|
+
...extra,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
|
|
98
|
+
const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
|
|
99
|
+
if (tamper) Object.assign(receipt.payload.claim, tamper); // tamper AFTER signing -> signature no longer binds
|
|
100
|
+
return receipt;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { publicKey: pub, mint, action, now: nowMs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Adapt an @emilia-protocol/gate instance into an EG-1 `invoke`. The gate must
|
|
108
|
+
* have been built trusting the harness public key. Uses gate.run() so the
|
|
109
|
+
* execution proof + reliance packet are produced on the allowed path.
|
|
110
|
+
*/
|
|
111
|
+
export function makeGateInvoke(gate, { selector = EG1_DEFAULT_SELECTOR, action = EG1_DEFAULT_ACTION } = {}) {
|
|
112
|
+
if (!gate || typeof gate.run !== 'function') {
|
|
113
|
+
throw new Error('makeGateInvoke requires an EMILIA Gate instance (with .run)');
|
|
114
|
+
}
|
|
115
|
+
return async ({ receipt, observedAction }) => {
|
|
116
|
+
const out = await gate.run(
|
|
117
|
+
{ selector, receipt, observedAction: observedAction ?? action },
|
|
118
|
+
async () => ({ eg1: 'side-effect-ran' }),
|
|
119
|
+
);
|
|
120
|
+
if (!out.ok) {
|
|
121
|
+
return { allowed: false, status: out.status, reason: out.authorization?.reason ?? 'refused' };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
allowed: true,
|
|
125
|
+
status: 200,
|
|
126
|
+
reason: out.authorization?.reason ?? 'allow',
|
|
127
|
+
decisionHash: out.authorization?.evidence?.hash ?? null,
|
|
128
|
+
execution: out.execution ?? null,
|
|
129
|
+
packet: out.packet ?? null,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pick = (r) => ({ allowed: !!r.allowed, status: r.status ?? null, reason: r.reason ?? null });
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drive a subject through the eight EG-1 checks and return a JSON report.
|
|
138
|
+
* @param {object} o
|
|
139
|
+
* @param {(scenario:object)=>Promise<object>} o.invoke the integration under test
|
|
140
|
+
* @param {object} o.harness from createEg1Harness()
|
|
141
|
+
* @param {object} [o.action] the high-risk action (defaults to the harness action)
|
|
142
|
+
*/
|
|
143
|
+
export async function runEg1({ invoke, harness, action } = {}) {
|
|
144
|
+
if (typeof invoke !== 'function') throw new Error('runEg1 requires an invoke(scenario) function');
|
|
145
|
+
if (!harness || typeof harness.mint !== 'function') throw new Error('runEg1 requires a harness from createEg1Harness()');
|
|
146
|
+
const act = action || harness.action || EG1_DEFAULT_ACTION;
|
|
147
|
+
const observed = act;
|
|
148
|
+
const drift = { ...act, amount_usd: Number(act.amount_usd ?? 0) + 1 };
|
|
149
|
+
|
|
150
|
+
const results = {};
|
|
151
|
+
const set = (id, pass, observed_) => { results[id] = { pass: !!pass, observed: observed_ }; };
|
|
152
|
+
|
|
153
|
+
// 1. missing receipt → 428
|
|
154
|
+
let r = await invoke({ receipt: null, observedAction: observed });
|
|
155
|
+
set('missing_receipt_refused', !r.allowed && r.status === 428, pick(r));
|
|
156
|
+
|
|
157
|
+
// 2. software receipt on a Class-A action → refused
|
|
158
|
+
r = await invoke({ receipt: harness.mint({ outcome: 'allow' }), observedAction: observed });
|
|
159
|
+
set('software_on_classA_refused', !r.allowed && /assurance/i.test(r.reason || ''), pick(r));
|
|
160
|
+
|
|
161
|
+
// 3. observed execution drift → refused
|
|
162
|
+
r = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff' }), observedAction: drift });
|
|
163
|
+
set('execution_drift_refused', !r.allowed && /binding/i.test(r.reason || ''), pick(r));
|
|
164
|
+
|
|
165
|
+
// 4. valid Class-A receipt → runs (capture for 5/7/8)
|
|
166
|
+
const valid = harness.mint({ outcome: 'allow_with_signoff' });
|
|
167
|
+
r = await invoke({ receipt: valid, observedAction: observed });
|
|
168
|
+
const validAllowed = r.allowed === true;
|
|
169
|
+
set('valid_classA_runs', validAllowed, pick(r));
|
|
170
|
+
|
|
171
|
+
// 7. execution proof binds to the authorization decision
|
|
172
|
+
const boundOk = validAllowed
|
|
173
|
+
&& !!r.execution && !!r.execution.authorizes_decision
|
|
174
|
+
&& !!r.decisionHash && r.execution.authorizes_decision === r.decisionHash;
|
|
175
|
+
set('execution_proof_binds', boundOk, {
|
|
176
|
+
authorizes_decision: r.execution?.authorizes_decision ?? null,
|
|
177
|
+
decision_hash: r.decisionHash ?? null,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// 8. reliance packet verdict "rely"
|
|
181
|
+
set('reliance_packet_rely', validAllowed && String(r.packet?.verdict || '').toLowerCase() === 'rely', {
|
|
182
|
+
verdict: r.packet?.verdict ?? null,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// 5. same receipt replay → refused
|
|
186
|
+
r = await invoke({ receipt: valid, observedAction: observed });
|
|
187
|
+
set('replay_refused', !r.allowed && /replay/i.test(r.reason || ''), pick(r));
|
|
188
|
+
|
|
189
|
+
// 6. tampered receipt → refused
|
|
190
|
+
r = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff', tamper: { amount_usd: 9_999_999 } }), observedAction: observed });
|
|
191
|
+
set('tampered_refused', !r.allowed && r.status === 428, pick(r));
|
|
192
|
+
|
|
193
|
+
const checks = EG1_CHECKS.map((c) => ({ id: c.id, title: c.title, ...results[c.id] }));
|
|
194
|
+
const passedCount = checks.filter((c) => c.pass).length;
|
|
195
|
+
const passed = passedCount === checks.length;
|
|
196
|
+
return {
|
|
197
|
+
standard: EG1_VERSION,
|
|
198
|
+
passed,
|
|
199
|
+
badge: passed ? 'EG-1 Enforced' : 'EG-1 not earned',
|
|
200
|
+
summary: { passed: passedCount, total: checks.length },
|
|
201
|
+
checks,
|
|
202
|
+
generated_at: new Date(harness.now ? harness.now() : Date.now()).toISOString(),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1 };
|
package/eg1.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EG-1 Conformance — runnable proof. Run: node eg1.mjs [--json]
|
|
3
|
+
*
|
|
4
|
+
* Self-certifies the reference EMILIA Gate against the eight EG-1 checks. An
|
|
5
|
+
* adopter earns EG-1 by pointing this harness at THEIR integration: build your
|
|
6
|
+
* gate trusting `harness.publicKey`, pass it to gateConformance(), and ship the
|
|
7
|
+
* JSON report + the "EG-1 Enforced" badge.
|
|
8
|
+
*
|
|
9
|
+
* import { createEg1Harness, gateConformance } from '@emilia-protocol/gate';
|
|
10
|
+
* const harness = createEg1Harness();
|
|
11
|
+
* const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey] });
|
|
12
|
+
* const report = await gateConformance({ gate, harness });
|
|
13
|
+
*
|
|
14
|
+
* Exit code is 0 only if all eight checks pass — CI-friendly.
|
|
15
|
+
* @license Apache-2.0
|
|
16
|
+
*/
|
|
17
|
+
import { gateConformanceSelfTest } from './index.js';
|
|
18
|
+
|
|
19
|
+
const report = await gateConformanceSelfTest();
|
|
20
|
+
|
|
21
|
+
if (process.argv.includes('--json')) {
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.log(JSON.stringify(report, null, 2));
|
|
24
|
+
process.exit(report.passed ? 0 : 1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const G = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
28
|
+
const R = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
29
|
+
const line = (s) => console.log(s);
|
|
30
|
+
|
|
31
|
+
line('='.repeat(64));
|
|
32
|
+
line(' EG-1 Conformance — does this integration ENFORCE EMILIA Gate?');
|
|
33
|
+
line('='.repeat(64));
|
|
34
|
+
for (const c of report.checks) {
|
|
35
|
+
line(` ${c.pass ? G('PASS') : R('FAIL')} ${c.title}`);
|
|
36
|
+
}
|
|
37
|
+
line(' ' + '-'.repeat(60));
|
|
38
|
+
line(` ${report.passed ? G(`✓ ${report.badge}`) : R(`✗ ${report.badge}`)} (${report.summary.passed}/${report.summary.total})`);
|
|
39
|
+
line('='.repeat(64));
|
|
40
|
+
process.exit(report.passed ? 0 : 1);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Execution-field binding for EMILIA Gate. This is the executor-side control
|
|
3
|
+
// that prevents "the signed claim said X, the system mutated Y".
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export const EXECUTION_BINDING_VERSION = 'EP-GATE-EXECUTION-BINDING-v1';
|
|
8
|
+
|
|
9
|
+
export function canonicalize(v) {
|
|
10
|
+
if (v === null || v === undefined) return JSON.stringify(v);
|
|
11
|
+
if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`;
|
|
12
|
+
if (typeof v === 'object') {
|
|
13
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',')}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(v);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hashCanonical(v) {
|
|
19
|
+
return crypto.createHash('sha256').update(canonicalize(v)).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hasValue(v) {
|
|
23
|
+
return v !== undefined && v !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalize(v) {
|
|
27
|
+
if (Array.isArray(v)) return [...new Set(v.map((x) => String(x)))].sort();
|
|
28
|
+
if (v && typeof v === 'object') {
|
|
29
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, normalize(v[k])]));
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function equalValue(a, b) {
|
|
35
|
+
return hashCanonical(normalize(a)) === hashCanonical(normalize(b));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function materialFieldsFor(requirement) {
|
|
39
|
+
const fields = requirement?.execution_binding?.required_fields;
|
|
40
|
+
return Array.isArray(fields) ? [...new Set(fields.filter(Boolean))] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Verifies that the signed claim and the executor-observed mutation fields
|
|
45
|
+
* match for the action pack. The executor must pass `observedAction` from the
|
|
46
|
+
* system of record, not from the agent request body.
|
|
47
|
+
*/
|
|
48
|
+
export function verifyExecutionBinding({ requirement, receipt, observedAction } = {}) {
|
|
49
|
+
const requiredFields = materialFieldsFor(requirement);
|
|
50
|
+
if (requiredFields.length === 0) {
|
|
51
|
+
return { ok: true, required: false, required_fields: [], signed_hash: null, observed_hash: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const signed = receipt?.payload?.claim || {};
|
|
55
|
+
const observed = observedAction || {};
|
|
56
|
+
const missingSigned = [];
|
|
57
|
+
const missingObserved = [];
|
|
58
|
+
const mismatched = [];
|
|
59
|
+
const signedValues = {};
|
|
60
|
+
const observedValues = {};
|
|
61
|
+
|
|
62
|
+
for (const field of requiredFields) {
|
|
63
|
+
const expected = signed[field];
|
|
64
|
+
const actual = observed[field];
|
|
65
|
+
if (!hasValue(expected)) {
|
|
66
|
+
missingSigned.push(field);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
signedValues[field] = normalize(expected);
|
|
70
|
+
if (!hasValue(actual)) {
|
|
71
|
+
missingObserved.push(field);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
observedValues[field] = normalize(actual);
|
|
75
|
+
if (!equalValue(expected, actual)) mismatched.push(field);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
'@version': EXECUTION_BINDING_VERSION,
|
|
80
|
+
ok: missingSigned.length === 0 && missingObserved.length === 0 && mismatched.length === 0,
|
|
81
|
+
required: true,
|
|
82
|
+
required_fields: requiredFields,
|
|
83
|
+
missing_signed_fields: missingSigned,
|
|
84
|
+
missing_observed_fields: missingObserved,
|
|
85
|
+
mismatched_fields: mismatched,
|
|
86
|
+
signed_hash: hashCanonical(signedValues),
|
|
87
|
+
observed_hash: hashCanonical(observedValues),
|
|
88
|
+
note: 'Executor MUST provide observedAction from the system of record; request-body fields are not a trust source.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
EXECUTION_BINDING_VERSION,
|
|
94
|
+
canonicalize,
|
|
95
|
+
hashCanonical,
|
|
96
|
+
materialFieldsFor,
|
|
97
|
+
verifyExecutionBinding,
|
|
98
|
+
};
|
package/index.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* adds the three things a firewall needs over a bare verifier: assurance-tier
|
|
22
22
|
* enforcement, replay defense, and the evidence log. Fails closed.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
const {
|
|
25
25
|
verifyEmiliaReceipt,
|
|
26
26
|
receiptChallenge,
|
|
27
27
|
receiptRequiredHeader,
|
|
@@ -29,11 +29,27 @@ import {
|
|
|
29
29
|
findActionRequirement,
|
|
30
30
|
RECEIPT_REQUIRED_STATUS,
|
|
31
31
|
RECEIPT_REQUIRED_HEADER,
|
|
32
|
-
}
|
|
32
|
+
} = await import('@emilia-protocol/require-receipt').catch(() => import('../require-receipt/index.js'));
|
|
33
33
|
import { MemoryConsumptionStore } from './store.js';
|
|
34
34
|
import { createEvidenceLog } from './evidence.js';
|
|
35
|
+
import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
|
|
36
|
+
import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
|
|
37
|
+
import { buildReliancePacket } from './reliance-packet.js';
|
|
38
|
+
import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
|
|
39
|
+
import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
40
|
+
import { classifyRetention, buildRetentionExport } from './retention.js';
|
|
35
41
|
|
|
36
42
|
export { MemoryConsumptionStore, createEvidenceLog };
|
|
43
|
+
export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
|
|
44
|
+
export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
45
|
+
export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
|
|
46
|
+
export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
|
|
47
|
+
export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
|
|
48
|
+
export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
|
|
49
|
+
export {
|
|
50
|
+
EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
|
|
51
|
+
createEg1Harness, makeGateInvoke, runEg1,
|
|
52
|
+
} from './eg1-conformance.js';
|
|
37
53
|
export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
|
|
38
54
|
const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
|
|
39
55
|
|
|
@@ -64,8 +80,15 @@ export function receiptAssuranceTier(doc) {
|
|
|
64
80
|
* @param {object} [opts.store] consumption store (default in-memory)
|
|
65
81
|
* @param {object} [opts.log] evidence log (default in-memory, hash-chained)
|
|
66
82
|
* @param {boolean} [opts.allowInlineKey=false] accept the receipt's own key (integrity, NOT trust)
|
|
83
|
+
* @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
|
|
84
|
+
* if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
|
|
85
|
+
* revoked) at its issuance time.
|
|
67
86
|
*/
|
|
68
|
-
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now } = {}) {
|
|
87
|
+
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null } = {}) {
|
|
88
|
+
// Production key custody: a registry (rotation + revocation) supersedes a flat
|
|
89
|
+
// trustedKeys list. A flat list is coerced to an always-valid registry, so
|
|
90
|
+
// existing callers are unchanged.
|
|
91
|
+
const registry = keyRegistry ? asKeyRegistry(keyRegistry) : (trustedKeys.length ? asKeyRegistry(trustedKeys) : null);
|
|
69
92
|
if (manifest) {
|
|
70
93
|
const m = validateActionRiskManifest(manifest);
|
|
71
94
|
if (!m.ok) throw new Error('EMILIA Gate: invalid action-risk manifest: ' + m.errors.join('; '));
|
|
@@ -88,10 +111,11 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
88
111
|
}
|
|
89
112
|
const evidence = log || createEvidenceLog({ strict: strictEvidence });
|
|
90
113
|
|
|
91
|
-
async function check({ selector = {}, receipt = null } = {}) {
|
|
114
|
+
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
|
|
92
115
|
const requirement = manifest ? findActionRequirement(manifest, selector) : null;
|
|
93
116
|
const action = requirement?.action_type || selector.action_type || selector.action || null;
|
|
94
117
|
const requiredTier = requirement?.assurance_class || selector.assurance_class || 'software';
|
|
118
|
+
const observed = observedAction || selector.observedAction || selector.actionDetails || null;
|
|
95
119
|
|
|
96
120
|
async function decide(allow, status, reason, extra = {}) {
|
|
97
121
|
const entry = {
|
|
@@ -105,6 +129,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
105
129
|
required_tier: requiredTier,
|
|
106
130
|
receipt_id: receipt?.payload?.receipt_id ?? null,
|
|
107
131
|
subject: receipt?.payload?.subject ?? null,
|
|
132
|
+
observed_action_hash: observed ? hashCanonical(observed) : null,
|
|
108
133
|
...extra,
|
|
109
134
|
};
|
|
110
135
|
let record;
|
|
@@ -144,8 +169,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
144
169
|
if (!receipt) {
|
|
145
170
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
|
|
146
171
|
}
|
|
147
|
-
// Signature / freshness / action-binding / outcome.
|
|
148
|
-
|
|
172
|
+
// Signature / freshness / action-binding / outcome. Production key custody:
|
|
173
|
+
// resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
|
|
174
|
+
// time. A revoked or out-of-window key is excluded, so its signature does not
|
|
175
|
+
// verify and the action is refused (fail closed).
|
|
176
|
+
const effectiveKeys = registry
|
|
177
|
+
? registry.keysValidAt(receipt?.payload?.created_at)
|
|
178
|
+
: trustedKeys;
|
|
179
|
+
const v = verifyEmiliaReceipt(receipt, { trustedKeys: effectiveKeys, allowInlineKey, action, maxAgeSec });
|
|
149
180
|
if (!v.ok) {
|
|
150
181
|
return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
|
|
151
182
|
}
|
|
@@ -154,6 +185,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
154
185
|
if ((TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
|
|
155
186
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', { have_tier: have, need_tier: requiredTier });
|
|
156
187
|
}
|
|
188
|
+
// The high-risk action packs define material fields that must be observed
|
|
189
|
+
// by the executor from the system of record. A signed, harmless-looking
|
|
190
|
+
// claim cannot authorize a different real mutation.
|
|
191
|
+
const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
|
|
192
|
+
if (!executionBinding.ok) {
|
|
193
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have });
|
|
194
|
+
}
|
|
157
195
|
// One-time consumption (replay defense). Require a stable, issuer-generated
|
|
158
196
|
// receipt_id — never fall back to a content hash, whose canonicalization can
|
|
159
197
|
// differ across language implementations and silently break replay detection
|
|
@@ -162,11 +200,21 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
162
200
|
if (!receiptId) {
|
|
163
201
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_rejected:missing_receipt_id');
|
|
164
202
|
}
|
|
165
|
-
|
|
203
|
+
let fresh;
|
|
204
|
+
if (consumptionMode === 'reserve') {
|
|
205
|
+
if (typeof consumption.reserve !== 'function') {
|
|
206
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'consumption_store_lacks_reserve', { consumption_key: receiptId });
|
|
207
|
+
}
|
|
208
|
+
fresh = await consumption.reserve(receiptId);
|
|
209
|
+
} else if (consumptionMode === 'none') {
|
|
210
|
+
fresh = true;
|
|
211
|
+
} else {
|
|
212
|
+
fresh = await consumption.consume(receiptId);
|
|
213
|
+
}
|
|
166
214
|
if (!fresh) {
|
|
167
215
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
|
|
168
216
|
}
|
|
169
|
-
return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have });
|
|
217
|
+
return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, execution_binding: executionBinding, consumption_mode: consumptionMode });
|
|
170
218
|
}
|
|
171
219
|
|
|
172
220
|
/** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
|
|
@@ -180,7 +228,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
180
228
|
const hdr = req.headers?.['x-emilia-receipt'];
|
|
181
229
|
if (hdr) { try { doc = JSON.parse(Buffer.from(hdr, 'base64').toString('utf8')); } catch { /* fallthrough */ } }
|
|
182
230
|
if (!doc && req.body?.emilia_receipt) doc = req.body.emilia_receipt;
|
|
183
|
-
const
|
|
231
|
+
const observedAction = typeof opts.observedAction === 'function'
|
|
232
|
+
? opts.observedAction(req)
|
|
233
|
+
: (opts.observedAction || req.emiliaObservedAction || null);
|
|
234
|
+
const out = await check({ selector, receipt: doc, observedAction });
|
|
184
235
|
if (!out.allow) {
|
|
185
236
|
res.setHeader(RECEIPT_REQUIRED_HEADER, out.header);
|
|
186
237
|
return res.status(out.status).json(out.challenge);
|
|
@@ -196,7 +247,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
196
247
|
* commits to the exact authorization decision (`authorizes_decision` = that
|
|
197
248
|
* decision's evidence hash), so authorization and execution are one chain.
|
|
198
249
|
*/
|
|
199
|
-
async function recordExecution({ authorization, outcome = 'executed', detail } = {}) {
|
|
250
|
+
async function recordExecution({ authorization, outcome = 'executed', detail, observedAction = null, executionBinding = null } = {}) {
|
|
200
251
|
const auth = authorization?.evidence || authorization || {};
|
|
201
252
|
return evidence.record({
|
|
202
253
|
kind: 'execution',
|
|
@@ -205,10 +256,47 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
205
256
|
action: authorization?.action ?? auth.action ?? null,
|
|
206
257
|
receipt_id: auth.receipt_id ?? null,
|
|
207
258
|
outcome, // 'executed' | 'failed'
|
|
259
|
+
observed_action_hash: observedAction ? hashCanonical(observedAction) : null,
|
|
260
|
+
execution_binding: executionBinding || authorization?.evidence?.execution_binding || authorization?.execution_binding || null,
|
|
208
261
|
...(detail !== undefined ? { detail } : {}),
|
|
209
262
|
});
|
|
210
263
|
}
|
|
211
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Recommended end-to-end path. Reserves the receipt, runs the side effect,
|
|
267
|
+
* commits one-time consumption only after success, and records execution.
|
|
268
|
+
* If the side effect throws, the reservation is released so the approval can
|
|
269
|
+
* be retried safely.
|
|
270
|
+
*/
|
|
271
|
+
async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
|
|
272
|
+
if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
|
|
273
|
+
const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
|
|
274
|
+
if (!authorization.allow) {
|
|
275
|
+
return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
|
|
276
|
+
}
|
|
277
|
+
const receiptId = authorization.evidence?.receipt_id;
|
|
278
|
+
let actionRan = false;
|
|
279
|
+
try {
|
|
280
|
+
const result = await fn(authorization);
|
|
281
|
+
actionRan = true;
|
|
282
|
+
if (typeof consumption.commit === 'function') await consumption.commit(receiptId);
|
|
283
|
+
if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
|
|
284
|
+
const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
|
|
285
|
+
return { ok: true, result, authorization, execution, packet: reliancePacket({ authorization, execution }) };
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (!actionRan && typeof consumption.release === 'function') await consumption.release(receiptId);
|
|
288
|
+
if (opts.recordExecution !== false) {
|
|
289
|
+
await recordExecution({
|
|
290
|
+
authorization,
|
|
291
|
+
outcome: 'failed',
|
|
292
|
+
detail: actionRan ? `post_execution_failure:${String(e?.message ?? e)}` : String(e?.message ?? e),
|
|
293
|
+
observedAction,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
throw e;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
212
300
|
/**
|
|
213
301
|
* Wrap any function so it runs only behind a passing gate check, and (unless
|
|
214
302
|
* disabled) emits an execution receipt after it runs — the full firewall loop:
|
|
@@ -218,26 +306,97 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
218
306
|
return async function guarded(...args) {
|
|
219
307
|
const selector = typeof opts.selector === 'function' ? opts.selector(...args) : (opts.selector || {});
|
|
220
308
|
const receipt = typeof opts.receipt === 'function' ? opts.receipt(...args) : (opts.receipt ?? null);
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
309
|
+
const observedAction = typeof opts.observedAction === 'function'
|
|
310
|
+
? opts.observedAction(...args)
|
|
311
|
+
: (opts.observedAction || selector.observedAction || null);
|
|
312
|
+
const out = await run({ selector, receipt, observedAction }, () => fn(...args), { recordExecution: opts.recordExecution });
|
|
313
|
+
if (!out.ok) {
|
|
314
|
+
const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
|
|
224
315
|
e.code = 'EMILIA_RECEIPT_REQUIRED';
|
|
225
|
-
e.gate = out;
|
|
226
|
-
throw e;
|
|
227
|
-
}
|
|
228
|
-
if (opts.recordExecution === false) return fn(...args);
|
|
229
|
-
try {
|
|
230
|
-
const result = await fn(...args);
|
|
231
|
-
await recordExecution({ authorization: out, outcome: 'executed' });
|
|
232
|
-
return result;
|
|
233
|
-
} catch (e) {
|
|
234
|
-
await recordExecution({ authorization: out, outcome: 'failed', detail: String(e?.message ?? e) });
|
|
316
|
+
e.gate = out.authorization;
|
|
235
317
|
throw e;
|
|
236
318
|
}
|
|
319
|
+
return out.result;
|
|
237
320
|
};
|
|
238
321
|
}
|
|
239
322
|
|
|
240
|
-
|
|
323
|
+
function reliancePacket({ authorization, execution = null, binding = null } = {}) {
|
|
324
|
+
return buildReliancePacket({
|
|
325
|
+
decision: authorization,
|
|
326
|
+
execution,
|
|
327
|
+
evidence,
|
|
328
|
+
manifest,
|
|
329
|
+
binding,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Retention classification over this gate's evidence log (hot/cold/expired/legal-hold). */
|
|
334
|
+
function retention(opts = {}) {
|
|
335
|
+
return classifyRetention(evidence.all(), opts);
|
|
336
|
+
}
|
|
337
|
+
/** The auditor/SIEM export manifest for this gate's evidence log. */
|
|
338
|
+
function retentionExport(opts = {}) {
|
|
339
|
+
return buildRetentionExport(evidence.all(), opts);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
check, run, recordExecution, middleware, guard, reliancePacket, evidence,
|
|
344
|
+
store: consumption, keyRegistry: registry, retention, retentionExport,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function createTrustedActionFirewall(opts = {}) {
|
|
349
|
+
const { manifest = createDefaultActionRiskManifest(), ...rest } = opts;
|
|
350
|
+
return createGate({ ...rest, manifest });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* EG-1 conformance for an existing gate. The gate MUST have been built trusting
|
|
355
|
+
* `harness.publicKey` (otherwise every valid receipt is rejected and the gate
|
|
356
|
+
* cannot earn EG-1). Returns the EG-1 JSON report.
|
|
357
|
+
* @param {object} o
|
|
358
|
+
* @param {object} o.gate an EMILIA Gate (createGate/createTrustedActionFirewall)
|
|
359
|
+
* @param {object} o.harness the harness whose key the gate trusts (createEg1Harness)
|
|
360
|
+
* @param {object} [o.action] the high-risk action to exercise
|
|
361
|
+
* @param {object} [o.selector] the manifest selector for that action
|
|
362
|
+
*/
|
|
363
|
+
export async function gateConformance({ gate, harness, action, selector = EG1_DEFAULT_SELECTOR } = {}) {
|
|
364
|
+
if (!gate || typeof gate.run !== 'function') {
|
|
365
|
+
throw new Error('gateConformance requires a gate built trusting harness.publicKey');
|
|
366
|
+
}
|
|
367
|
+
if (!harness) throw new Error('gateConformance requires the harness whose key the gate trusts');
|
|
368
|
+
const act = action || harness.action;
|
|
369
|
+
const invoke = makeGateInvoke(gate, { selector, action: act });
|
|
370
|
+
return runEg1({ invoke, harness, action: act });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Self-certify the reference gate: build a default Trusted Action Firewall that
|
|
375
|
+
* trusts a fresh EG-1 harness key, then run all eight checks. This is the
|
|
376
|
+
* canonical "EMILIA Gate earns EG-1" proof — runnable as a CLI (`eg1.mjs`),
|
|
377
|
+
* shown on /gate, and the template an adopter copies for their integration.
|
|
378
|
+
*/
|
|
379
|
+
export async function gateConformanceSelfTest({ now } = {}) {
|
|
380
|
+
const harness = createEg1Harness({ now });
|
|
381
|
+
const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], now });
|
|
382
|
+
return gateConformance({ gate, harness });
|
|
241
383
|
}
|
|
242
384
|
|
|
243
|
-
export default {
|
|
385
|
+
export default {
|
|
386
|
+
createGate,
|
|
387
|
+
createTrustedActionFirewall,
|
|
388
|
+
receiptAssuranceTier,
|
|
389
|
+
MemoryConsumptionStore,
|
|
390
|
+
createEvidenceLog,
|
|
391
|
+
ASSURANCE_TIERS,
|
|
392
|
+
DEFAULT_GATE_MANIFEST,
|
|
393
|
+
HIGH_RISK_ACTION_PACKS,
|
|
394
|
+
gateConformance,
|
|
395
|
+
gateConformanceSelfTest,
|
|
396
|
+
createEg1Harness,
|
|
397
|
+
runEg1,
|
|
398
|
+
createKeyRegistry,
|
|
399
|
+
asKeyRegistry,
|
|
400
|
+
classifyRetention,
|
|
401
|
+
buildRetentionExport,
|
|
402
|
+
};
|