@emilia-protocol/gate 0.9.0 → 0.9.2
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/action-control-manifest.js +9 -3
- package/breakglass.js +349 -0
- package/deploy/helm/README.md +110 -0
- package/deploy/helm/emilia-gate/Chart.yaml +39 -0
- package/deploy/helm/emilia-gate/templates/NOTES.txt +48 -0
- package/deploy/helm/emilia-gate/templates/configmap.yaml +27 -0
- package/deploy/helm/emilia-gate/templates/deployment.yaml +134 -0
- package/deploy/helm/emilia-gate/templates/service.yaml +28 -0
- package/deploy/helm/emilia-gate/templates/servicemonitor.yaml +41 -0
- package/deploy/helm/emilia-gate/values.yaml +169 -0
- package/deploy/terraform/README.md +138 -0
- package/deploy/terraform/main.tf +256 -0
- package/deploy/terraform/outputs.tf +33 -0
- package/deploy/terraform/variables.tf +205 -0
- package/deploy/terraform/versions.tf +18 -0
- package/enterprise.js +174 -0
- package/index.js +132 -10
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +41 -4
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/external-verification.js +223 -0
- package/reports/reperform.js +365 -0
- package/reports/underwriter.js +340 -0
- package/roster.js +265 -0
- package/siem.js +236 -0
- package/store-postgres.js +129 -0
|
@@ -135,7 +135,7 @@ export function createDefaultActionControlManifest({
|
|
|
135
135
|
return {
|
|
136
136
|
'@version': ACTION_CONTROL_MANIFEST_VERSION,
|
|
137
137
|
'$schema': ACTION_CONTROL_SCHEMA_URL,
|
|
138
|
-
profile: '
|
|
138
|
+
profile: 'agent-action-control',
|
|
139
139
|
service: {
|
|
140
140
|
name: service.name || 'EMILIA Gate default action controls',
|
|
141
141
|
issuer: service.issuer || 'https://www.emiliaprotocol.ai',
|
|
@@ -169,8 +169,8 @@ export function validateActionControlManifest(manifest) {
|
|
|
169
169
|
if (manifest['@version'] !== ACTION_CONTROL_MANIFEST_VERSION) {
|
|
170
170
|
errors.push(`@version must be ${ACTION_CONTROL_MANIFEST_VERSION}`);
|
|
171
171
|
}
|
|
172
|
-
if (manifest.profile !== 'emilia.action-control') {
|
|
173
|
-
errors.push('profile must be emilia.action-control');
|
|
172
|
+
if (manifest.profile !== 'agent-action-control' && manifest.profile !== 'emilia.action-control') {
|
|
173
|
+
errors.push('profile must be agent-action-control (or the deployed alias emilia.action-control)');
|
|
174
174
|
}
|
|
175
175
|
if (!manifest.service || typeof manifest.service !== 'object') {
|
|
176
176
|
errors.push('service object is required');
|
|
@@ -193,6 +193,12 @@ export function validateActionControlManifest(manifest) {
|
|
|
193
193
|
if (typeof action.receipt_required !== 'boolean') errors.push(`${prefix}.receipt_required must be boolean`);
|
|
194
194
|
if (!RISK_LEVELS.has(action.risk)) errors.push(`${prefix}.risk must be low|medium|high|critical`);
|
|
195
195
|
if (!ASSURANCE_CLASSES.has(action.assurance_class)) errors.push(`${prefix}.assurance_class must be software|class_a|quorum`);
|
|
196
|
+
// Key-class floor: a critical action must be bound to a human key. normalizeAssurance()
|
|
197
|
+
// defaults a missing or unrecognized tier to the weakest 'software', so this also fails
|
|
198
|
+
// closed on a critical action whose tier was omitted rather than silently downgrading it.
|
|
199
|
+
if (action.receipt_required && action.risk === 'critical' && action.assurance_class === 'software') {
|
|
200
|
+
errors.push(`${prefix}.assurance_class must be class_a or quorum when risk is critical`);
|
|
201
|
+
}
|
|
196
202
|
if (action.receipt_required) {
|
|
197
203
|
if (!Number.isFinite(action.max_age_sec) || action.max_age_sec <= 0) errors.push(`${prefix}.max_age_sec must be positive`);
|
|
198
204
|
const control = action.control;
|
package/breakglass.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — break-glass as EVIDENCE, never a bypass (EP-GATE-BREAKGLASS-v1).
|
|
4
|
+
*
|
|
5
|
+
* An emergency override is a FIRST-CLASS SIGNED ARTIFACT, not a config flag.
|
|
6
|
+
* There is no "disable the gate" switch anywhere in this module: to act outside
|
|
7
|
+
* the normal receipt path, operators mint a break-glass authorization — an
|
|
8
|
+
* M-of-N Ed25519 multi-signature over canonical JSON (sorted keys, same idiom
|
|
9
|
+
* as receipts/entitlements/evidence) of ONE shared grant:
|
|
10
|
+
*
|
|
11
|
+
* { grant_id, scope: { action_types[] }, window: { not_before, expires_at },
|
|
12
|
+
* reason, incident_ref, threshold }
|
|
13
|
+
*
|
|
14
|
+
* Every signer signs the SAME payload, so the artifact proves that `threshold`
|
|
15
|
+
* DISTINCT pinned principals authorized exactly this scope, for exactly this
|
|
16
|
+
* window, for exactly this incident. The grant is:
|
|
17
|
+
* - SCOPED — valid only for the listed action_types; anything else refuses;
|
|
18
|
+
* - BOUNDED — valid only inside [not_before, expires_at];
|
|
19
|
+
* - ATTRIBUTED — reason + incident_ref are REQUIRED; an override with no
|
|
20
|
+
* stated cause is refused, not logged-and-allowed;
|
|
21
|
+
* - SINGLE-USE — consumed through the same consumption-store contract as
|
|
22
|
+
* receipts (store.js); consumption is committed BEFORE use, so
|
|
23
|
+
* a crash mid-override burns the grant instead of leaving it
|
|
24
|
+
* replayable (fail direction: unusable, never reusable);
|
|
25
|
+
* - LOGGED — `buildBreakGlassEvidence` shapes a kind:'breakglass' entry
|
|
26
|
+
* for the tamper-evident evidence log.
|
|
27
|
+
*
|
|
28
|
+
* THE MODULE'S CONTRACT: NO EVIDENCE ENTRY, NO OVERRIDE. Callers MUST append
|
|
29
|
+
* the break-glass evidence entry via a strict evidence log
|
|
30
|
+
* (createEvidenceLog({ strict: true })) and see record() succeed BEFORE
|
|
31
|
+
* executing the overridden action. If the entry cannot be durably recorded,
|
|
32
|
+
* the override MUST NOT run — the whole point of break-glass-as-evidence is
|
|
33
|
+
* that an unaccounted emergency action is indistinguishable from an attack.
|
|
34
|
+
*
|
|
35
|
+
* Verification FAILS CLOSED with machine-readable reasons: threshold unmet,
|
|
36
|
+
* non-distinct signer kids, expired, not-yet-valid, out-of-scope action_type,
|
|
37
|
+
* tampered payload, unknown kid, malformed anything → { valid:false, reason }.
|
|
38
|
+
* A grant carrying ANY signature that does not verify is refused outright —
|
|
39
|
+
* we never "count the good ones" past a bad one.
|
|
40
|
+
*
|
|
41
|
+
* Pure functions: inputs in, verdict out. Time is injected (`now`), never read
|
|
42
|
+
* from the wall clock implicitly, so verification is deterministic.
|
|
43
|
+
*/
|
|
44
|
+
import crypto from 'node:crypto';
|
|
45
|
+
|
|
46
|
+
export const BREAKGLASS_VERSION = 'EP-GATE-BREAKGLASS-v1';
|
|
47
|
+
export const BREAKGLASS_EVIDENCE_KIND = 'breakglass';
|
|
48
|
+
|
|
49
|
+
function sha256hex(s) {
|
|
50
|
+
return crypto.createHash('sha256').update(s).digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Canonical JSON (recursive sorted keys) — matches @emilia-protocol/verify. */
|
|
54
|
+
function canonical(v) {
|
|
55
|
+
if (v === null || v === undefined) return JSON.stringify(v);
|
|
56
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
57
|
+
if (typeof v === 'object') {
|
|
58
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify(v);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toMs(t) {
|
|
64
|
+
if (t == null) return null;
|
|
65
|
+
const ms = typeof t === 'number' ? t : Date.parse(t);
|
|
66
|
+
return Number.isFinite(ms) ? ms : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every refusal shape is identical and machine-readable. Fail closed. */
|
|
70
|
+
function refuse(reason, extra = {}) {
|
|
71
|
+
return { valid: false, reason, ...extra };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isNonEmptyString(s) {
|
|
75
|
+
return typeof s === 'string' && s.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isActionTypeList(a) {
|
|
79
|
+
return Array.isArray(a) && a.length > 0 && a.every(isNonEmptyString);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mint a break-glass authorization: every signer signs the canonical JSON of
|
|
84
|
+
* the SAME grant payload. Throws on invalid fields — a malformed grant must
|
|
85
|
+
* never be issued, only refused. Signer kids must already be distinct at mint
|
|
86
|
+
* time: one principal can never pre-fill two threshold slots.
|
|
87
|
+
*
|
|
88
|
+
* grant_id is CONTENT-DERIVED (sha-256 of the canonical grant fields), so the
|
|
89
|
+
* id is deterministic and re-minting the identical grant yields the identical
|
|
90
|
+
* single-use consumption key — the same emergency authorization cannot be
|
|
91
|
+
* "refreshed" into extra uses by minting it twice.
|
|
92
|
+
*
|
|
93
|
+
* @param {Array<{ privateKey: crypto.KeyObject, kid: string }>} signers
|
|
94
|
+
* @param {object} fields { scope: { action_types: string[] }, window: { not_before, expires_at }, reason, incident_ref, threshold }
|
|
95
|
+
* @returns {{ '@version': string, payload: object, signatures: Array<{ kid: string, algorithm: 'Ed25519', value: string }> }}
|
|
96
|
+
*/
|
|
97
|
+
export function mintBreakGlassAuthorization(signers, {
|
|
98
|
+
scope, window: win, reason, incident_ref, threshold,
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!Array.isArray(signers) || signers.length === 0) {
|
|
101
|
+
throw new Error('breakglass: signers must be a non-empty array of { privateKey, kid }');
|
|
102
|
+
}
|
|
103
|
+
for (const s of signers) {
|
|
104
|
+
if (!s || !s.privateKey || !isNonEmptyString(s.kid)) {
|
|
105
|
+
throw new Error('breakglass: each signer needs a privateKey and a kid');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const kids = signers.map((s) => s.kid);
|
|
109
|
+
if (new Set(kids).size !== kids.length) {
|
|
110
|
+
throw new Error('breakglass: signer kids must be distinct — one principal cannot fill two threshold slots');
|
|
111
|
+
}
|
|
112
|
+
if (!scope || !isActionTypeList(scope.action_types)) {
|
|
113
|
+
throw new Error('breakglass: scope.action_types must be a non-empty array of action-type strings');
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isInteger(threshold) || threshold < 1) {
|
|
116
|
+
throw new Error('breakglass: threshold must be an integer >= 1');
|
|
117
|
+
}
|
|
118
|
+
if (threshold > signers.length) {
|
|
119
|
+
throw new Error(`breakglass: threshold ${threshold} exceeds signer count ${signers.length} — the grant could never verify`);
|
|
120
|
+
}
|
|
121
|
+
const nbf = toMs(win?.not_before);
|
|
122
|
+
const exp = toMs(win?.expires_at);
|
|
123
|
+
if (nbf == null || exp == null) {
|
|
124
|
+
throw new Error('breakglass: window.not_before and window.expires_at are required (ISO or ms)');
|
|
125
|
+
}
|
|
126
|
+
if (exp <= nbf) throw new Error('breakglass: window.expires_at must be after window.not_before');
|
|
127
|
+
// An override with no stated cause must never exist — attribution is the deal.
|
|
128
|
+
if (!isNonEmptyString(reason)) throw new Error('breakglass: reason is required');
|
|
129
|
+
if (!isNonEmptyString(incident_ref)) throw new Error('breakglass: incident_ref is required');
|
|
130
|
+
|
|
131
|
+
const core = {
|
|
132
|
+
scope: { action_types: scope.action_types.slice() },
|
|
133
|
+
window: { not_before: win.not_before, expires_at: win.expires_at },
|
|
134
|
+
reason,
|
|
135
|
+
incident_ref,
|
|
136
|
+
threshold,
|
|
137
|
+
};
|
|
138
|
+
const grant_id = `bg_${sha256hex(canonical(core))}`;
|
|
139
|
+
const payload = { grant_id, ...core };
|
|
140
|
+
const msg = Buffer.from(canonical(payload), 'utf8');
|
|
141
|
+
const signatures = signers.map(({ privateKey, kid }) => ({
|
|
142
|
+
kid,
|
|
143
|
+
algorithm: 'Ed25519',
|
|
144
|
+
value: crypto.sign(null, msg, privateKey).toString('base64url'),
|
|
145
|
+
}));
|
|
146
|
+
return { '@version': BREAKGLASS_VERSION, payload, signatures };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve a base64url SPKI-DER key for `kid` from a map or an entry list. */
|
|
150
|
+
function issuerKeyFor(issuerKeys, kid) {
|
|
151
|
+
if (!issuerKeys) return null;
|
|
152
|
+
if (Array.isArray(issuerKeys)) {
|
|
153
|
+
const e = issuerKeys.find((x) => x && x.kid === kid && typeof x.key === 'string');
|
|
154
|
+
return e ? e.key : null;
|
|
155
|
+
}
|
|
156
|
+
const k = issuerKeys[kid];
|
|
157
|
+
return typeof k === 'string' ? k : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Verify a break-glass grant against pinned issuer keys. NEVER throws for a
|
|
162
|
+
* bad artifact — every failure resolves to { valid:false, reason } so the
|
|
163
|
+
* refusal itself is loggable. FAILS CLOSED on every path:
|
|
164
|
+
* no_grant | grant_unparseable | grant_malformed | unsupported_version |
|
|
165
|
+
* unsupported_algorithm | invalid_threshold | invalid_scope |
|
|
166
|
+
* missing_reason | missing_incident_ref | duplicate_signer |
|
|
167
|
+
* threshold_unmet | unknown_kid | bad_signature |
|
|
168
|
+
* invalid_validity_window | not_yet_valid | expired |
|
|
169
|
+
* action_type_required | out_of_scope
|
|
170
|
+
*
|
|
171
|
+
* A grant can never nominate its own keys: every signature's kid must resolve
|
|
172
|
+
* to a PINNED key, and EVERY listed signature must verify — one tampered or
|
|
173
|
+
* unknown signature refuses the whole grant. The validity window is checked
|
|
174
|
+
* only AFTER the signatures, so the timestamps themselves are authenticated.
|
|
175
|
+
*
|
|
176
|
+
* @param {object|string|null} grantJson the artifact (object or JSON string)
|
|
177
|
+
* @param {object} o
|
|
178
|
+
* @param {object|Array<{kid:string,key:string}>} o.issuerKeys pinned kid -> base64url SPKI-DER public key
|
|
179
|
+
* @param {number|string|function} [o.now=Date.now] injected clock (ms, ISO, or () => ms)
|
|
180
|
+
* @param {string} o.actionType the action the caller wants to override — REQUIRED (scope cannot be checked without it)
|
|
181
|
+
* @returns {{ valid: boolean, reason: string, grant_id?: string, incident_ref?: string, scope?: object, window?: object, threshold?: number, signer_kids?: string[] }}
|
|
182
|
+
*/
|
|
183
|
+
export function verifyBreakGlass(grantJson, { issuerKeys, now = Date.now, actionType } = {}) {
|
|
184
|
+
if (grantJson == null || grantJson === '') return refuse('no_grant');
|
|
185
|
+
|
|
186
|
+
let doc = grantJson;
|
|
187
|
+
if (typeof doc === 'string') {
|
|
188
|
+
try { doc = JSON.parse(doc); } catch { return refuse('grant_unparseable'); }
|
|
189
|
+
}
|
|
190
|
+
if (!doc || typeof doc !== 'object') return refuse('grant_malformed');
|
|
191
|
+
if (doc['@version'] !== BREAKGLASS_VERSION) return refuse('unsupported_version');
|
|
192
|
+
|
|
193
|
+
const p = doc.payload;
|
|
194
|
+
const sigs = doc.signatures;
|
|
195
|
+
if (!p || typeof p !== 'object' || !Array.isArray(sigs) || sigs.length === 0) {
|
|
196
|
+
return refuse('grant_malformed');
|
|
197
|
+
}
|
|
198
|
+
if (!isNonEmptyString(p.grant_id)) return refuse('grant_malformed');
|
|
199
|
+
if (!Number.isInteger(p.threshold) || p.threshold < 1) return refuse('invalid_threshold');
|
|
200
|
+
if (!p.scope || !isActionTypeList(p.scope.action_types)) return refuse('invalid_scope');
|
|
201
|
+
// Attribution is non-optional: an anonymous or causeless override is refused.
|
|
202
|
+
if (!isNonEmptyString(p.reason)) return refuse('missing_reason');
|
|
203
|
+
if (!isNonEmptyString(p.incident_ref)) return refuse('missing_incident_ref');
|
|
204
|
+
|
|
205
|
+
for (const s of sigs) {
|
|
206
|
+
if (!s || typeof s !== 'object' || !isNonEmptyString(s.kid) || typeof s.value !== 'string') {
|
|
207
|
+
return refuse('grant_malformed');
|
|
208
|
+
}
|
|
209
|
+
if (s.algorithm !== 'Ed25519') return refuse('unsupported_algorithm', { kid: s.kid });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Distinct principals: one kid may fill exactly one threshold slot.
|
|
213
|
+
const kids = sigs.map((s) => s.kid);
|
|
214
|
+
if (new Set(kids).size !== kids.length) {
|
|
215
|
+
const dup = kids.find((k, i) => kids.indexOf(k) !== i);
|
|
216
|
+
return refuse('duplicate_signer', { kid: dup });
|
|
217
|
+
}
|
|
218
|
+
if (sigs.length < p.threshold) {
|
|
219
|
+
return refuse('threshold_unmet', { threshold: p.threshold, signatures: sigs.length });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Every listed signature must verify against a PINNED key. One unknown kid
|
|
223
|
+
// or one bad signature refuses the whole grant — never count past a failure.
|
|
224
|
+
const msg = Buffer.from(canonical(p), 'utf8');
|
|
225
|
+
for (const s of sigs) {
|
|
226
|
+
const keyB64 = issuerKeyFor(issuerKeys, s.kid);
|
|
227
|
+
if (!keyB64) return refuse('unknown_kid', { kid: s.kid });
|
|
228
|
+
let ok = false;
|
|
229
|
+
try {
|
|
230
|
+
const pub = crypto.createPublicKey({ key: Buffer.from(keyB64, 'base64url'), format: 'der', type: 'spki' });
|
|
231
|
+
ok = crypto.verify(null, msg, pub, Buffer.from(s.value, 'base64url'));
|
|
232
|
+
} catch { ok = false; }
|
|
233
|
+
if (!ok) return refuse('bad_signature', { kid: s.kid });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validity window — checked only AFTER the signatures, so the timestamps
|
|
237
|
+
// themselves are authenticated. Both bounds are required; unparseable fails closed.
|
|
238
|
+
const nowMs = typeof now === 'function' ? now() : toMs(now);
|
|
239
|
+
const nbf = toMs(p.window?.not_before);
|
|
240
|
+
const exp = toMs(p.window?.expires_at);
|
|
241
|
+
if (nbf == null || exp == null || nowMs == null) return refuse('invalid_validity_window');
|
|
242
|
+
if (nowMs < nbf) return refuse('not_yet_valid', { not_before: p.window.not_before });
|
|
243
|
+
if (nowMs > exp) return refuse('expired', { expires_at: p.window.expires_at });
|
|
244
|
+
|
|
245
|
+
// Scope: without knowing the action there is nothing to authorize — refuse.
|
|
246
|
+
if (!isNonEmptyString(actionType)) return refuse('action_type_required');
|
|
247
|
+
if (!p.scope.action_types.includes(actionType)) {
|
|
248
|
+
return refuse('out_of_scope', { action_type: actionType, scope: p.scope.action_types.slice() });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
valid: true,
|
|
253
|
+
reason: 'breakglass_verified',
|
|
254
|
+
grant_id: p.grant_id,
|
|
255
|
+
incident_ref: p.incident_ref,
|
|
256
|
+
scope: { action_types: p.scope.action_types.slice() },
|
|
257
|
+
window: { not_before: p.window.not_before, expires_at: p.window.expires_at },
|
|
258
|
+
threshold: p.threshold,
|
|
259
|
+
signer_kids: kids.slice(),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* SINGLE-USE consumption via the consumption-store contract (store.js):
|
|
265
|
+
* `consume(key)` returns true the FIRST time, false on every replay, and marks
|
|
266
|
+
* the key seen BEFORE the caller acts — consumption is committed before use.
|
|
267
|
+
* If the process crashes after consume() and before the override, the grant is
|
|
268
|
+
* burned, not replayable: the fail direction is unusable, never reusable.
|
|
269
|
+
*
|
|
270
|
+
* Accepts the grant document ({ payload: { grant_id } }) or a verified result
|
|
271
|
+
* ({ grant_id }). NEVER throws — a missing store, missing grant_id, or a store
|
|
272
|
+
* error all refuse with a machine-readable reason.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} grant break-glass grant document or verifyBreakGlass result
|
|
275
|
+
* @param {{ consume(key: string): Promise<boolean> }} store consumption store (store.js contract)
|
|
276
|
+
* @returns {Promise<{ consumed: boolean, reason: string, key?: string }>}
|
|
277
|
+
*/
|
|
278
|
+
export async function consumeBreakGlass(grant, store) {
|
|
279
|
+
const grantId = grant?.payload?.grant_id ?? grant?.grant_id;
|
|
280
|
+
if (!isNonEmptyString(grantId)) return { consumed: false, reason: 'missing_grant_id' };
|
|
281
|
+
if (!store || typeof store.consume !== 'function') {
|
|
282
|
+
return { consumed: false, reason: 'no_consumption_store' };
|
|
283
|
+
}
|
|
284
|
+
const key = `breakglass:${grantId}`;
|
|
285
|
+
let first = false;
|
|
286
|
+
try {
|
|
287
|
+
first = (await store.consume(key)) === true;
|
|
288
|
+
} catch {
|
|
289
|
+
// The store could not commit the consumption — fail CLOSED: we cannot
|
|
290
|
+
// prove single-use, so the override must not run.
|
|
291
|
+
return { consumed: false, reason: 'store_error', key };
|
|
292
|
+
}
|
|
293
|
+
if (!first) return { consumed: false, reason: 'already_consumed', key };
|
|
294
|
+
return { consumed: true, reason: 'consumed', key };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Shape an evidence-log entry (kind 'breakglass') committing to the EXACT
|
|
299
|
+
* grant artifact (grant_hash = sha-256 of canonical grant) and the decision
|
|
300
|
+
* taken under it. Append it via the hash-chained evidence log — with
|
|
301
|
+
* { strict: true } so a sink failure refuses the override.
|
|
302
|
+
*
|
|
303
|
+
* THE CONTRACT: NO EVIDENCE ENTRY, NO OVERRIDE. `evidence.record(entry)` must
|
|
304
|
+
* SUCCEED before the overridden action executes; if it throws, refuse. This
|
|
305
|
+
* function never throws, even for a malformed grant or missing decision — the
|
|
306
|
+
* refusal of a bad grant must itself be loggable. A missing/indeterminate
|
|
307
|
+
* decision is recorded as allow:false (fail closed).
|
|
308
|
+
*
|
|
309
|
+
* @param {object} grant the break-glass grant document as presented (even if invalid)
|
|
310
|
+
* @param {object} decision e.g. { allow, reason, action_type } from verify/consume
|
|
311
|
+
* @param {object} [o]
|
|
312
|
+
* @param {number|string|function} [o.now=Date.now] injected clock for the entry timestamp
|
|
313
|
+
* @returns {object} entry ready for createEvidenceLog().record()
|
|
314
|
+
*/
|
|
315
|
+
export function buildBreakGlassEvidence(grant, decision, { now = Date.now } = {}) {
|
|
316
|
+
const nowMs = typeof now === 'function' ? now() : toMs(now);
|
|
317
|
+
const p = (grant && typeof grant === 'object' && grant.payload && typeof grant.payload === 'object')
|
|
318
|
+
? grant.payload : null;
|
|
319
|
+
const sigs = (grant && Array.isArray(grant.signatures)) ? grant.signatures : [];
|
|
320
|
+
const d = (decision && typeof decision === 'object') ? decision : {};
|
|
321
|
+
return {
|
|
322
|
+
kind: BREAKGLASS_EVIDENCE_KIND,
|
|
323
|
+
'@version': BREAKGLASS_VERSION,
|
|
324
|
+
at: new Date(nowMs ?? 0).toISOString(),
|
|
325
|
+
grant_id: p?.grant_id ?? null,
|
|
326
|
+
incident_ref: p?.incident_ref ?? null,
|
|
327
|
+
grant_reason: p?.reason ?? null,
|
|
328
|
+
scope: p?.scope?.action_types?.slice?.() ?? null,
|
|
329
|
+
threshold: p?.threshold ?? null,
|
|
330
|
+
signer_kids: sigs.map((s) => s?.kid ?? null),
|
|
331
|
+
// Commits the log entry to the exact artifact presented — a later dispute
|
|
332
|
+
// can prove which grant (tampered or not) the decision was taken under.
|
|
333
|
+
grant_hash: sha256hex(canonical(grant ?? null)),
|
|
334
|
+
decision: {
|
|
335
|
+
allow: d.allow === true, // fail closed: anything else records a refusal
|
|
336
|
+
reason: isNonEmptyString(d.reason) ? d.reason : 'unspecified',
|
|
337
|
+
action_type: d.action_type ?? null,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export default {
|
|
343
|
+
mintBreakGlassAuthorization,
|
|
344
|
+
verifyBreakGlass,
|
|
345
|
+
consumeBreakGlass,
|
|
346
|
+
buildBreakGlassEvidence,
|
|
347
|
+
BREAKGLASS_VERSION,
|
|
348
|
+
BREAKGLASS_EVIDENCE_KIND,
|
|
349
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
# EMILIA Gate — reference Helm chart (`emilia-gate`)
|
|
3
|
+
|
|
4
|
+
**Contract:** `EP-GATE-HELM-v1` · **Status: reference chart, experimental, BYOC.**
|
|
5
|
+
|
|
6
|
+
A reference Kubernetes deployment of the [EMILIA Gate](../../README.md) — the
|
|
7
|
+
Trusted Action Firewall. Deny-by-default enforcement for consequential machine
|
|
8
|
+
actions: an action runs only with a valid, in-scope, sufficiently-assured,
|
|
9
|
+
fresh, non-replayed EMILIA authorization receipt. *No receipt, no execution.*
|
|
10
|
+
|
|
11
|
+
This chart is meant to be **read, reviewed, and adapted** for your cluster
|
|
12
|
+
(bring-your-own-cloud). It is not a managed product and there is no official
|
|
13
|
+
public image yet — build and push your own image of the gate service.
|
|
14
|
+
|
|
15
|
+
## What the chart renders
|
|
16
|
+
|
|
17
|
+
| Template | Object | Notes |
|
|
18
|
+
| ----------------------------- | --------------- | ----- |
|
|
19
|
+
| `templates/deployment.yaml` | `Deployment` | Single-container gate service; env-driven config; hardened pod defaults (non-root, read-only rootfs, all capabilities dropped, no SA token); liveness + readiness probes; manifest-checksum annotation rolls pods on config change. |
|
|
20
|
+
| `templates/service.yaml` | `Service` | `ClusterIP` by default, port 8080 → container `http`. |
|
|
21
|
+
| `templates/configmap.yaml` | `ConfigMap` | The `EP-ACTION-RISK-MANIFEST-v0.1` JSON (which actions are guarded, what assurance each requires). Skipped when `manifest.existingConfigMap` is set. |
|
|
22
|
+
| `templates/servicemonitor.yaml` | `ServiceMonitor` (stub) | Rendered only when `metrics.enabled=true`; scrapes the gate's `/metrics` endpoint; requires the Prometheus Operator CRDs. |
|
|
23
|
+
| `templates/NOTES.txt` | post-install notes | Rollout/port-forward/rotation crib sheet. |
|
|
24
|
+
|
|
25
|
+
## Security invariants (fail-closed)
|
|
26
|
+
|
|
27
|
+
1. **Key material is never rendered.** Issuer public keys arrive via an
|
|
28
|
+
**existing** Kubernetes Secret you create out-of-band; the chart only emits
|
|
29
|
+
a `secretKeyRef`. Rendering **fails** (`required`) if
|
|
30
|
+
`issuerKeys.existingSecret` is unset — a gate with no pinned issuers must
|
|
31
|
+
not deploy.
|
|
32
|
+
2. **Strict evidence log by default** (`gate.evidenceStrict=true` →
|
|
33
|
+
`EP_GATE_EVIDENCE_STRICT=true`): the gate refuses to authorize an action it
|
|
34
|
+
cannot durably account for.
|
|
35
|
+
3. **Hardened pod defaults**: `runAsNonRoot`, `readOnlyRootFilesystem`,
|
|
36
|
+
`capabilities: drop [ALL]`, `seccompProfile: RuntimeDefault`,
|
|
37
|
+
`automountServiceAccountToken: false`. Loosen deliberately via values, not
|
|
38
|
+
accidentally.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
# 1. Create the pinned-issuer-keys Secret yourself (NEVER in values files).
|
|
44
|
+
# issuer-keys.json = JSON array of base64url SPKI-DER Ed25519 public keys
|
|
45
|
+
# (or a {kid: key} map), i.e. the keys the gate trusts to sign receipts.
|
|
46
|
+
kubectl -n emilia create secret generic emilia-gate-issuer-keys \
|
|
47
|
+
--from-file=issuer-keys.json=./issuer-keys.json
|
|
48
|
+
|
|
49
|
+
# 2. Install the chart, pointing at YOUR image and the existing Secret.
|
|
50
|
+
helm install gate ./emilia-gate \
|
|
51
|
+
--namespace emilia --create-namespace \
|
|
52
|
+
--set image.repository=YOUR_REGISTRY/emilia-gate \
|
|
53
|
+
--set image.tag=0.9.0 \
|
|
54
|
+
--set issuerKeys.existingSecret=emilia-gate-issuer-keys
|
|
55
|
+
|
|
56
|
+
# 3. (Optional) expose /metrics + ServiceMonitor stub (needs Prometheus Operator).
|
|
57
|
+
# --set metrics.enabled=true
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Omitting `issuerKeys.existingSecret` makes `helm install`/`template` refuse to
|
|
61
|
+
render — that is intentional.
|
|
62
|
+
|
|
63
|
+
## Environment contract (what the container is expected to read)
|
|
64
|
+
|
|
65
|
+
The chart is env-driven; the gate service image consumes:
|
|
66
|
+
|
|
67
|
+
| Env var | Source | Default |
|
|
68
|
+
| ------------------------ | ----------------------------------- | ------- |
|
|
69
|
+
| `NODE_ENV` | fixed | `production` |
|
|
70
|
+
| `EP_GATE_PORT` | `gate.port` | `8080` |
|
|
71
|
+
| `EP_GATE_LOG_LEVEL` | `gate.logLevel` | `info` |
|
|
72
|
+
| `EP_GATE_EVIDENCE_STRICT`| `gate.evidenceStrict` | `true` |
|
|
73
|
+
| `EP_GATE_MANIFEST_PATH` | ConfigMap mount | `/etc/emilia-gate/action-risk-manifest.json` |
|
|
74
|
+
| `EP_GATE_METRICS_ENABLED`| `metrics.enabled` | `false` |
|
|
75
|
+
| `EP_GATE_ISSUER_KEYS` | `secretKeyRef` → your existing Secret | — (required) |
|
|
76
|
+
|
|
77
|
+
Anything else (e.g. a Redis URL for a durable consumption store) goes in
|
|
78
|
+
`gate.extraEnv` as verbatim `EnvVar` objects — `valueFrom` your own
|
|
79
|
+
Secrets/ConfigMaps there rather than inlining values.
|
|
80
|
+
|
|
81
|
+
## Key values
|
|
82
|
+
|
|
83
|
+
| Value | Default | Meaning |
|
|
84
|
+
| ----- | ------- | ------- |
|
|
85
|
+
| `replicaCount` | `2` | Gate replicas. NOTE: replay defense across replicas needs a shared consumption store (see `createDurableConsumptionStore` in `packages/gate/store.js`); configure the backend via `gate.extraEnv`. |
|
|
86
|
+
| `image.repository` / `image.tag` | `ghcr.io/emilia-protocol/gate` / chart `appVersion` | BYOC image. |
|
|
87
|
+
| `issuerKeys.existingSecret` | — **required** | Name of your pre-created Secret with pinned issuer keys. |
|
|
88
|
+
| `issuerKeys.secretKey` | `issuer-keys.json` | Key inside that Secret. |
|
|
89
|
+
| `manifest.inline` | subset of default packs | Action-risk manifest rendered to the ConfigMap. Replace with your org's manifest. |
|
|
90
|
+
| `manifest.existingConfigMap` | `""` | Bring your own manifest ConfigMap (chart's ConfigMap is then skipped; pods do not auto-roll on changes to it). |
|
|
91
|
+
| `metrics.enabled` | `false` | Sets `EP_GATE_METRICS_ENABLED=true` and renders the ServiceMonitor stub. |
|
|
92
|
+
| `probes.liveness.path` / `probes.readiness.path` | `/healthz` / `/readyz` | HTTP probe paths on the gate port. |
|
|
93
|
+
| `resources` | 100m/128Mi → 500m/256Mi | Requests/limits defaults. |
|
|
94
|
+
|
|
95
|
+
## Honest limitations
|
|
96
|
+
|
|
97
|
+
- **Experimental.** Not battle-tested; `helm lint`-clean is not a security
|
|
98
|
+
review. Read the rendered output (`helm template`) before applying.
|
|
99
|
+
- The default in-memory consumption store and evidence sink are per-pod. For
|
|
100
|
+
fleets (`replicaCount > 1`) wire a shared/durable backend or replayed
|
|
101
|
+
receipts on another pod are only caught by receipt freshness, not
|
|
102
|
+
consumption.
|
|
103
|
+
- The ServiceMonitor is a **stub**: no TLS/auth on the scrape endpoint —
|
|
104
|
+
adapt it to your monitoring stack.
|
|
105
|
+
- No Ingress/NetworkPolicy/HPA templates on purpose: the gate is an internal
|
|
106
|
+
policy-enforcement point; expose it deliberately.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
Apache-2.0 — see the repository root.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# EMILIA Gate — reference Helm chart (EP-GATE-HELM-v1).
|
|
3
|
+
#
|
|
4
|
+
# HONEST LABEL: this is a REFERENCE chart for a BYOC (bring-your-own-cloud)
|
|
5
|
+
# deployment of the Trusted Action Firewall. It is EXPERIMENTAL and is meant
|
|
6
|
+
# to be read, reviewed, and adapted — not consumed blindly in production.
|
|
7
|
+
# It never contains or renders key material: issuer keys arrive via an
|
|
8
|
+
# EXISTING Kubernetes Secret that you create out-of-band.
|
|
9
|
+
apiVersion: v2
|
|
10
|
+
name: emilia-gate
|
|
11
|
+
description: >-
|
|
12
|
+
EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for
|
|
13
|
+
consequential machine actions: an action runs only with a valid, in-scope,
|
|
14
|
+
sufficiently-assured, non-replayed EMILIA authorization receipt.
|
|
15
|
+
Reference chart, experimental, BYOC. Issuer keys are referenced from an
|
|
16
|
+
existing Secret and are never rendered by this chart.
|
|
17
|
+
type: application
|
|
18
|
+
# Chart version (the packaging), independent of the gate's app version.
|
|
19
|
+
version: 0.1.0
|
|
20
|
+
# Version of @emilia-protocol/gate this chart tracks; used as the default
|
|
21
|
+
# image tag when .Values.image.tag is empty.
|
|
22
|
+
appVersion: "0.9.0"
|
|
23
|
+
kubeVersion: ">=1.23.0-0"
|
|
24
|
+
home: https://emiliaprotocol.ai
|
|
25
|
+
sources:
|
|
26
|
+
- https://github.com/FutureEnterprises/emilia-protocol
|
|
27
|
+
keywords:
|
|
28
|
+
- emilia
|
|
29
|
+
- authorization
|
|
30
|
+
- receipt
|
|
31
|
+
- trusted-action-firewall
|
|
32
|
+
- policy-enforcement-point
|
|
33
|
+
- ai-safety
|
|
34
|
+
annotations:
|
|
35
|
+
emiliaprotocol.ai/chart-contract: EP-GATE-HELM-v1
|
|
36
|
+
emiliaprotocol.ai/maturity: experimental
|
|
37
|
+
emiliaprotocol.ai/deployment-model: byoc
|
|
38
|
+
artifacthub.io/prerelease: "true"
|
|
39
|
+
artifacthub.io/license: Apache-2.0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
2
|
+
{{- $fullname := default (printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-") .Values.fullnameOverride }}
|
|
3
|
+
EMILIA Gate — Trusted Action Firewall (chart {{ .Chart.Name }}-{{ .Chart.Version }}, contract EP-GATE-HELM-v1)
|
|
4
|
+
|
|
5
|
+
Release: {{ .Release.Name }} (namespace {{ .Release.Namespace }})
|
|
6
|
+
Image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
|
|
7
|
+
Replicas: {{ .Values.replicaCount }}
|
|
8
|
+
|
|
9
|
+
STATUS: reference chart — EXPERIMENTAL, BYOC. Review the rendered manifests
|
|
10
|
+
before trusting production traffic to them.
|
|
11
|
+
|
|
12
|
+
1. Issuer keys (already enforced at render time — this release referenced the
|
|
13
|
+
existing Secret "{{ .Values.issuerKeys.existingSecret }}", key
|
|
14
|
+
"{{ .Values.issuerKeys.secretKey }}"). The chart never renders key
|
|
15
|
+
material; rotate keys by updating that Secret and restarting the pods:
|
|
16
|
+
|
|
17
|
+
kubectl -n {{ .Release.Namespace }} rollout restart deploy/{{ $fullname }}
|
|
18
|
+
|
|
19
|
+
2. Check the gate is up:
|
|
20
|
+
|
|
21
|
+
kubectl -n {{ .Release.Namespace }} rollout status deploy/{{ $fullname }}
|
|
22
|
+
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $fullname }} {{ .Values.service.port }}:{{ .Values.service.port }}
|
|
23
|
+
curl -s http://127.0.0.1:{{ .Values.service.port }}{{ .Values.probes.readiness.path }}
|
|
24
|
+
|
|
25
|
+
3. Action-risk manifest:
|
|
26
|
+
{{- if .Values.manifest.existingConfigMap }}
|
|
27
|
+
Using YOUR ConfigMap "{{ .Values.manifest.existingConfigMap }}"
|
|
28
|
+
(key "{{ .Values.manifest.fileName }}"). Pods do NOT roll automatically
|
|
29
|
+
when you change it — restart the deployment after edits.
|
|
30
|
+
{{- else }}
|
|
31
|
+
Rendered from values into ConfigMap "{{ $fullname }}" and mounted at
|
|
32
|
+
/etc/emilia-gate/{{ .Values.manifest.fileName }}. Pods roll automatically
|
|
33
|
+
when the manifest changes (checksum annotation). The default manifest is a
|
|
34
|
+
SUBSET of the built-in high-risk packs — replace it with your own.
|
|
35
|
+
{{- end }}
|
|
36
|
+
|
|
37
|
+
4. Metrics:
|
|
38
|
+
{{- if .Values.metrics.enabled }}
|
|
39
|
+
ENABLED — /metrics is exposed and a ServiceMonitor stub was rendered
|
|
40
|
+
(requires the Prometheus Operator CRDs in this cluster).
|
|
41
|
+
{{- else }}
|
|
42
|
+
disabled. Enable with --set metrics.enabled=true to expose {{ .Values.metrics.path }}
|
|
43
|
+
and render a ServiceMonitor stub.
|
|
44
|
+
{{- end }}
|
|
45
|
+
|
|
46
|
+
The gate FAILS CLOSED: no valid, in-scope, sufficiently-assured, fresh,
|
|
47
|
+
unused receipt — no execution. Every decision is appended to the
|
|
48
|
+
tamper-evident evidence log. No receipt, no execution.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# emilia-gate reference chart (EP-GATE-HELM-v1) — action-risk manifest ConfigMap.
|
|
3
|
+
# Carries the EP-ACTION-RISK-MANIFEST-v0.1 JSON that tells the gate which
|
|
4
|
+
# actions are guarded and what assurance each requires. Config only — NEVER
|
|
5
|
+
# key material (issuer keys live in an existing Secret you create yourself).
|
|
6
|
+
# Skipped entirely when manifest.existingConfigMap points at your own.
|
|
7
|
+
{{- if not .Values.manifest.existingConfigMap }}
|
|
8
|
+
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
9
|
+
{{- $fullname := default (printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-") .Values.fullnameOverride }}
|
|
10
|
+
apiVersion: v1
|
|
11
|
+
kind: ConfigMap
|
|
12
|
+
metadata:
|
|
13
|
+
name: {{ $fullname }}
|
|
14
|
+
namespace: {{ .Release.Namespace }}
|
|
15
|
+
labels:
|
|
16
|
+
app.kubernetes.io/name: {{ $name }}
|
|
17
|
+
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
18
|
+
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
19
|
+
app.kubernetes.io/component: trusted-action-firewall
|
|
20
|
+
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
21
|
+
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
|
|
22
|
+
emiliaprotocol.ai/chart-contract: EP-GATE-HELM-v1
|
|
23
|
+
emiliaprotocol.ai/maturity: experimental
|
|
24
|
+
data:
|
|
25
|
+
{{ .Values.manifest.fileName }}: |-
|
|
26
|
+
{{- .Values.manifest.inline | toPrettyJson | nindent 4 }}
|
|
27
|
+
{{- end }}
|