@emilia-protocol/gate 0.9.5 → 0.10.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/CHANGELOG.md +70 -0
- package/LICENSE +190 -0
- package/README.md +75 -16
- package/action-control-manifest.js +26 -0
- package/adapters/_kit.js +47 -5
- package/adapters/aws.js +15 -4
- package/adapters/github-demo.mjs +25 -22
- package/adapters/github.js +1 -1
- package/adapters/jira.js +1 -0
- package/adapters/linear.js +1 -0
- package/adapters/salesforce.js +1 -0
- package/adapters/stripe.js +1 -1
- package/adapters/supabase.js +26 -4
- package/adapters/vercel.js +33 -4
- package/aec-execution.js +1 -3
- package/breakglass.js +285 -51
- package/control-plane.js +341 -0
- package/coverage.js +722 -0
- package/custody-demo.mjs +13 -3
- package/demo.mjs +9 -3
- package/deploy/helm/README.md +16 -8
- package/deploy/helm/emilia-gate/Chart.yaml +3 -1
- package/deploy/helm/emilia-gate/templates/NOTES.txt +3 -2
- package/deploy/helm/emilia-gate/templates/deployment.yaml +55 -1
- package/deploy/helm/emilia-gate/values.yaml +18 -2
- package/deploy/helm/emilia-gate-service/Chart.yaml +11 -0
- package/deploy/helm/emilia-gate-service/README.md +81 -0
- package/deploy/helm/emilia-gate-service/templates/NOTES.txt +12 -0
- package/deploy/helm/emilia-gate-service/templates/_helpers.tpl +83 -0
- package/deploy/helm/emilia-gate-service/templates/deployment.yaml +153 -0
- package/deploy/helm/emilia-gate-service/templates/migration-job.yaml +73 -0
- package/deploy/helm/emilia-gate-service/templates/networkpolicy.yaml +113 -0
- package/deploy/helm/emilia-gate-service/templates/pdb.yaml +14 -0
- package/deploy/helm/emilia-gate-service/templates/service.yaml +20 -0
- package/deploy/helm/emilia-gate-service/templates/serviceaccount.yaml +8 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/001_gate.sql +26 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/002_runtime_access.sql +32 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/gate.config.mjs +29 -0
- package/deploy/helm/emilia-gate-service/tests/render-check.sh +145 -0
- package/deploy/helm/emilia-gate-service/values.schema.json +145 -0
- package/deploy/helm/emilia-gate-service/values.yaml +166 -0
- package/deploy/sql/001-runtime.sql +707 -0
- package/deploy/terraform/README.md +24 -14
- package/deploy/terraform/main.tf +59 -0
- package/deploy/terraform/service/README.md +81 -0
- package/deploy/terraform/service/main.tf +718 -0
- package/deploy/terraform/service/outputs.tf +19 -0
- package/deploy/terraform/service/tests/validate.sh +24 -0
- package/deploy/terraform/service/tests/validation.tftest.hcl +168 -0
- package/deploy/terraform/service/variables.tf +459 -0
- package/deploy/terraform/service/versions.tf +10 -0
- package/deploy/terraform/variables.tf +95 -2
- package/deployment-attestation.js +248 -0
- package/eg1-conformance.js +46 -12
- package/enterprise.js +6 -2
- package/ep-assure.mjs +3 -2
- package/evidence-postgres.js +357 -0
- package/evidence.js +10 -3
- package/execution-binding.js +141 -26
- package/index.js +556 -115
- package/key-registry.js +51 -19
- package/mcp.js +4 -2
- package/network-witness.js +500 -0
- package/package.json +25 -5
- package/reliance-kernel.js +5 -6
- package/reliance-packet.js +43 -10
- package/reports/assurance-package.js +45 -19
- package/reports/reperform.js +78 -18
- package/reports/underwriter.js +1 -1
- package/settlement.js +300 -0
- package/store-postgres.js +14 -0
- package/store.js +26 -5
- package/strict-json.js +86 -0
- package/witness-postgres.js +97 -0
package/breakglass.js
CHANGED
|
@@ -25,12 +25,11 @@
|
|
|
25
25
|
* - LOGGED — `buildBreakGlassEvidence` shapes a kind:'breakglass' entry
|
|
26
26
|
* for the tamper-evident evidence log.
|
|
27
27
|
*
|
|
28
|
-
* THE MODULE'S CONTRACT:
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* that an unaccounted emergency action is indistinguishable from an attack.
|
|
28
|
+
* THE MODULE'S EXECUTION CONTRACT: use `runBreakGlass`. It is the one high-level
|
|
29
|
+
* path that enforces pinned-policy verification, permanent fleet-safe
|
|
30
|
+
* consumption, strict evidence acknowledgement, and only then effect invocation.
|
|
31
|
+
* The lower-level verify/consume/evidence helpers are composable primitives; no
|
|
32
|
+
* one of them alone authorizes or executes an override.
|
|
34
33
|
*
|
|
35
34
|
* Verification FAILS CLOSED with machine-readable reasons: threshold unmet,
|
|
36
35
|
* non-distinct signer kids, expired, not-yet-valid, out-of-scope action_type,
|
|
@@ -42,6 +41,10 @@
|
|
|
42
41
|
* from the wall clock implicitly, so verification is deterministic.
|
|
43
42
|
*/
|
|
44
43
|
import crypto from 'node:crypto';
|
|
44
|
+
import { strictJsonGate } from './strict-json.js';
|
|
45
|
+
import { canonicalize as canonical } from './execution-binding.js';
|
|
46
|
+
import { verifyEvidenceRecord } from './evidence.js';
|
|
47
|
+
import { isSecureConsumptionStore } from './store.js';
|
|
45
48
|
|
|
46
49
|
export const BREAKGLASS_VERSION = 'EP-GATE-BREAKGLASS-v1';
|
|
47
50
|
export const BREAKGLASS_EVIDENCE_KIND = 'breakglass';
|
|
@@ -50,16 +53,6 @@ function sha256hex(s) {
|
|
|
50
53
|
return crypto.createHash('sha256').update(s).digest('hex');
|
|
51
54
|
}
|
|
52
55
|
|
|
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
56
|
function toMs(t) {
|
|
64
57
|
if (t == null) return null;
|
|
65
58
|
const ms = typeof t === 'number' ? t : Date.parse(t);
|
|
@@ -83,7 +76,8 @@ function isActionTypeList(a) {
|
|
|
83
76
|
* Mint a break-glass authorization: every signer signs the canonical JSON of
|
|
84
77
|
* the SAME grant payload. Throws on invalid fields — a malformed grant must
|
|
85
78
|
* never be issued, only refused. Signer kids must already be distinct at mint
|
|
86
|
-
* time: one
|
|
79
|
+
* time: one key can never pre-fill two threshold slots. Relying-party principal
|
|
80
|
+
* uniqueness is enforced at verification through the pinned policy roster.
|
|
87
81
|
*
|
|
88
82
|
* grant_id is CONTENT-DERIVED (sha-256 of the canonical grant fields), so the
|
|
89
83
|
* id is deterministic and re-minting the identical grant yields the identical
|
|
@@ -109,11 +103,25 @@ export function mintBreakGlassAuthorization(signers, {
|
|
|
109
103
|
if (new Set(kids).size !== kids.length) {
|
|
110
104
|
throw new Error('breakglass: signer kids must be distinct — one principal cannot fill two threshold slots');
|
|
111
105
|
}
|
|
106
|
+
let signerFingerprints;
|
|
107
|
+
try {
|
|
108
|
+
signerFingerprints = signers.map((s) => {
|
|
109
|
+
const publicKey = crypto.createPublicKey(s.privateKey);
|
|
110
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') throw new Error('not Ed25519');
|
|
111
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' });
|
|
112
|
+
return sha256hex(spki);
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error('breakglass: every signer privateKey must be an Ed25519 key');
|
|
116
|
+
}
|
|
117
|
+
if (new Set(signerFingerprints).size !== signerFingerprints.length) {
|
|
118
|
+
throw new Error('breakglass: signer SPKI keys must be distinct — one key cannot fill two threshold slots');
|
|
119
|
+
}
|
|
112
120
|
if (!scope || !isActionTypeList(scope.action_types)) {
|
|
113
121
|
throw new Error('breakglass: scope.action_types must be a non-empty array of action-type strings');
|
|
114
122
|
}
|
|
115
|
-
if (!Number.isInteger(threshold) || threshold <
|
|
116
|
-
throw new Error('breakglass: threshold must be an integer >=
|
|
123
|
+
if (!Number.isInteger(threshold) || threshold < 2) {
|
|
124
|
+
throw new Error('breakglass: threshold must be an integer >= 2');
|
|
117
125
|
}
|
|
118
126
|
if (threshold > signers.length) {
|
|
119
127
|
throw new Error(`breakglass: threshold ${threshold} exceeds signer count ${signers.length} — the grant could never verify`);
|
|
@@ -157,37 +165,103 @@ function issuerKeyFor(issuerKeys, kid) {
|
|
|
157
165
|
return typeof k === 'string' ? k : null;
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
function isPlainObject(value) {
|
|
169
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
170
|
+
const prototype = Object.getPrototypeOf(value);
|
|
171
|
+
return prototype === Object.prototype || prototype === null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pinnedKey(key) {
|
|
175
|
+
if (typeof key !== 'string' || !/^[A-Za-z0-9_-]+$/.test(key)) {
|
|
176
|
+
throw new Error('invalid pinned key encoding');
|
|
177
|
+
}
|
|
178
|
+
const bytes = Buffer.from(key, 'base64url');
|
|
179
|
+
if (bytes.length === 0 || bytes.toString('base64url') !== key) {
|
|
180
|
+
throw new Error('non-canonical pinned key encoding');
|
|
181
|
+
}
|
|
182
|
+
const publicKey = crypto.createPublicKey({ key: bytes, format: 'der', type: 'spki' });
|
|
183
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') throw new Error('pinned key is not Ed25519');
|
|
184
|
+
const canonicalSpki = publicKey.export({ type: 'spki', format: 'der' });
|
|
185
|
+
return {
|
|
186
|
+
publicKey,
|
|
187
|
+
fingerprint: `sha256:${sha256hex(canonicalSpki)}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Normalize the relying-party trust policy; presenter material is never used. */
|
|
192
|
+
function normalizePinnedPolicy(policy, issuerKeys) {
|
|
193
|
+
if (!isPlainObject(policy)) return { ok: false, reason: 'missing_policy' };
|
|
194
|
+
if (!Number.isSafeInteger(policy.minimum_threshold) || policy.minimum_threshold < 2
|
|
195
|
+
|| !Array.isArray(policy.roster)) {
|
|
196
|
+
return { ok: false, reason: 'invalid_policy' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const roster = new Map();
|
|
201
|
+
for (const entry of policy.roster) {
|
|
202
|
+
if (!isPlainObject(entry) || !isNonEmptyString(entry.kid)
|
|
203
|
+
|| !isNonEmptyString(entry.principal_id) || roster.has(entry.kid)) {
|
|
204
|
+
return { ok: false, reason: 'invalid_policy' };
|
|
205
|
+
}
|
|
206
|
+
const key = isNonEmptyString(entry.key) ? entry.key : issuerKeyFor(issuerKeys, entry.kid);
|
|
207
|
+
const keyInfo = pinnedKey(key);
|
|
208
|
+
roster.set(entry.kid, {
|
|
209
|
+
kid: entry.kid,
|
|
210
|
+
principal_id: entry.principal_id,
|
|
211
|
+
...keyInfo,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const members = [...roster.values()];
|
|
215
|
+
if (new Set(members.map((entry) => entry.principal_id)).size < policy.minimum_threshold
|
|
216
|
+
|| new Set(members.map((entry) => entry.fingerprint)).size < policy.minimum_threshold) {
|
|
217
|
+
return { ok: false, reason: 'invalid_policy' };
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
minimum_threshold: policy.minimum_threshold,
|
|
222
|
+
roster,
|
|
223
|
+
};
|
|
224
|
+
} catch {
|
|
225
|
+
return { ok: false, reason: 'invalid_policy' };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
160
229
|
/**
|
|
161
|
-
* Verify a break-glass grant against pinned
|
|
230
|
+
* Verify a break-glass grant against a relying-party-pinned policy. NEVER throws for a
|
|
162
231
|
* bad artifact — every failure resolves to { valid:false, reason } so the
|
|
163
232
|
* refusal itself is loggable. FAILS CLOSED on every path:
|
|
164
233
|
* no_grant | grant_unparseable | grant_malformed | unsupported_version |
|
|
165
234
|
* unsupported_algorithm | invalid_threshold | invalid_scope |
|
|
166
|
-
* missing_reason | missing_incident_ref |
|
|
167
|
-
*
|
|
235
|
+
* missing_reason | missing_incident_ref | missing_policy | invalid_policy |
|
|
236
|
+
* duplicate_signer | duplicate_signer_principal | duplicate_signer_key |
|
|
237
|
+
* policy_threshold_unmet | threshold_unmet | signer_not_in_roster | bad_signature |
|
|
168
238
|
* invalid_validity_window | not_yet_valid | expired |
|
|
169
239
|
* action_type_required | out_of_scope
|
|
170
240
|
*
|
|
171
|
-
* A grant can never nominate its own
|
|
172
|
-
*
|
|
173
|
-
*
|
|
241
|
+
* A grant can never nominate its own threshold, roster, identities, or keys.
|
|
242
|
+
* EVERY listed signature must verify against its pinned roster entry — one
|
|
243
|
+
* tampered or unrostered signature refuses the whole grant. The validity window is checked
|
|
174
244
|
* only AFTER the signatures, so the timestamps themselves are authenticated.
|
|
175
245
|
*
|
|
176
246
|
* @param {object|string|null} grantJson the artifact (object or JSON string)
|
|
177
247
|
* @param {object} o
|
|
178
|
-
* @param {
|
|
248
|
+
* @param {{minimum_threshold:number,roster:Array<{kid:string,principal_id:string,key?:string}>}} o.policy relying-party policy
|
|
249
|
+
* @param {object|Array<{kid:string,key:string}>} [o.issuerKeys] optional pinned keys when roster entries omit key
|
|
179
250
|
* @param {number|string|function} [o.now=Date.now] injected clock (ms, ISO, or () => ms)
|
|
180
251
|
* @param {string} o.actionType the action the caller wants to override — REQUIRED (scope cannot be checked without it)
|
|
181
252
|
* @returns {{ valid: boolean, reason: string, grant_id?: string, incident_ref?: string, scope?: object, window?: object, threshold?: number, signer_kids?: string[] }}
|
|
182
253
|
*/
|
|
183
|
-
|
|
254
|
+
function verifyBreakGlassInternal(grantJson, { policy, issuerKeys, now = Date.now, actionType } = {}) {
|
|
184
255
|
if (grantJson == null || grantJson === '') return refuse('no_grant');
|
|
185
256
|
|
|
186
257
|
let doc = grantJson;
|
|
187
258
|
if (typeof doc === 'string') {
|
|
188
|
-
try {
|
|
259
|
+
try {
|
|
260
|
+
if (Buffer.byteLength(doc, 'utf8') > 1024 * 1024 || !strictJsonGate(doc).ok) return refuse('grant_unparseable');
|
|
261
|
+
doc = JSON.parse(doc);
|
|
262
|
+
} catch { return refuse('grant_unparseable'); }
|
|
189
263
|
}
|
|
190
|
-
if (!doc || typeof doc !== 'object') return refuse('grant_malformed');
|
|
264
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return refuse('grant_malformed');
|
|
191
265
|
if (doc['@version'] !== BREAKGLASS_VERSION) return refuse('unsupported_version');
|
|
192
266
|
|
|
193
267
|
const p = doc.payload;
|
|
@@ -202,6 +276,9 @@ export function verifyBreakGlass(grantJson, { issuerKeys, now = Date.now, action
|
|
|
202
276
|
if (!isNonEmptyString(p.reason)) return refuse('missing_reason');
|
|
203
277
|
if (!isNonEmptyString(p.incident_ref)) return refuse('missing_incident_ref');
|
|
204
278
|
|
|
279
|
+
const pinned = normalizePinnedPolicy(policy, issuerKeys);
|
|
280
|
+
if (!pinned.ok) return refuse(pinned.reason);
|
|
281
|
+
|
|
205
282
|
for (const s of sigs) {
|
|
206
283
|
if (!s || typeof s !== 'object' || !isNonEmptyString(s.kid) || typeof s.value !== 'string') {
|
|
207
284
|
return refuse('grant_malformed');
|
|
@@ -209,33 +286,57 @@ export function verifyBreakGlass(grantJson, { issuerKeys, now = Date.now, action
|
|
|
209
286
|
if (s.algorithm !== 'Ed25519') return refuse('unsupported_algorithm', { kid: s.kid });
|
|
210
287
|
}
|
|
211
288
|
|
|
212
|
-
// Distinct
|
|
289
|
+
// Distinct roster slots, pinned human principals, and canonical SPKI keys are
|
|
290
|
+
// independent predicates. Aliases cannot satisfy separation of duties.
|
|
213
291
|
const kids = sigs.map((s) => s.kid);
|
|
214
292
|
if (new Set(kids).size !== kids.length) {
|
|
215
293
|
const dup = kids.find((k, i) => kids.indexOf(k) !== i);
|
|
216
294
|
return refuse('duplicate_signer', { kid: dup });
|
|
217
295
|
}
|
|
296
|
+
const signerEntries = [];
|
|
297
|
+
for (const kid of kids) {
|
|
298
|
+
const entry = pinned.roster.get(kid);
|
|
299
|
+
if (!entry) return refuse('signer_not_in_roster', { kid });
|
|
300
|
+
signerEntries.push(entry);
|
|
301
|
+
}
|
|
302
|
+
const principalIds = signerEntries.map((entry) => entry.principal_id);
|
|
303
|
+
if (new Set(principalIds).size !== principalIds.length) {
|
|
304
|
+
const principalId = principalIds.find((id, i) => principalIds.indexOf(id) !== i);
|
|
305
|
+
return refuse('duplicate_signer_principal', { principal_id: principalId });
|
|
306
|
+
}
|
|
307
|
+
const fingerprints = signerEntries.map((entry) => entry.fingerprint);
|
|
308
|
+
if (new Set(fingerprints).size !== fingerprints.length) {
|
|
309
|
+
const fingerprint = fingerprints.find((value, i) => fingerprints.indexOf(value) !== i);
|
|
310
|
+
return refuse('duplicate_signer_key', { spki_fingerprint: fingerprint });
|
|
311
|
+
}
|
|
218
312
|
if (sigs.length < p.threshold) {
|
|
219
313
|
return refuse('threshold_unmet', { threshold: p.threshold, signatures: sigs.length });
|
|
220
314
|
}
|
|
315
|
+
if (sigs.length < pinned.minimum_threshold) {
|
|
316
|
+
return refuse('policy_threshold_unmet', {
|
|
317
|
+
required_threshold: pinned.minimum_threshold,
|
|
318
|
+
presented_threshold: p.threshold,
|
|
319
|
+
signatures: sigs.length,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
221
322
|
|
|
222
|
-
// Every listed signature must verify against
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
let ok
|
|
323
|
+
// Every listed signature must verify against its pinned roster key. Never
|
|
324
|
+
// count past one failure.
|
|
325
|
+
let msg;
|
|
326
|
+
try { msg = Buffer.from(canonical(p), 'utf8'); } catch { return refuse('grant_malformed'); }
|
|
327
|
+
for (let i = 0; i < sigs.length; i++) {
|
|
328
|
+
const s = sigs[i];
|
|
329
|
+
let ok;
|
|
229
330
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (!ok) return refuse('bad_signature', { kid: s.kid });
|
|
331
|
+
ok = crypto.verify(null, msg, signerEntries[i].publicKey, Buffer.from(s.value, 'base64url'));
|
|
332
|
+
} catch { return refuse('bad_signature', { kid: s.kid }); }
|
|
333
|
+
if (ok !== true) return refuse('bad_signature', { kid: s.kid });
|
|
234
334
|
}
|
|
235
335
|
|
|
236
336
|
// Validity window — checked only AFTER the signatures, so the timestamps
|
|
237
337
|
// themselves are authenticated. Both bounds are required; unparseable fails closed.
|
|
238
|
-
|
|
338
|
+
let nowMs;
|
|
339
|
+
try { nowMs = typeof now === 'function' ? toMs(now()) : toMs(now); } catch { nowMs = null; }
|
|
239
340
|
const nbf = toMs(p.window?.not_before);
|
|
240
341
|
const exp = toMs(p.window?.expires_at);
|
|
241
342
|
if (nbf == null || exp == null || nowMs == null) return refuse('invalid_validity_window');
|
|
@@ -256,10 +357,18 @@ export function verifyBreakGlass(grantJson, { issuerKeys, now = Date.now, action
|
|
|
256
357
|
scope: { action_types: p.scope.action_types.slice() },
|
|
257
358
|
window: { not_before: p.window.not_before, expires_at: p.window.expires_at },
|
|
258
359
|
threshold: p.threshold,
|
|
360
|
+
required_threshold: Math.max(p.threshold, pinned.minimum_threshold),
|
|
361
|
+
policy_minimum_threshold: pinned.minimum_threshold,
|
|
259
362
|
signer_kids: kids.slice(),
|
|
363
|
+
signer_principal_ids: principalIds.slice(),
|
|
364
|
+
signer_spki_fingerprints: fingerprints.slice(),
|
|
260
365
|
};
|
|
261
366
|
}
|
|
262
367
|
|
|
368
|
+
export function verifyBreakGlass(grantJson, options = {}) {
|
|
369
|
+
try { return verifyBreakGlassInternal(grantJson, options); } catch { return refuse('grant_malformed'); }
|
|
370
|
+
}
|
|
371
|
+
|
|
263
372
|
/**
|
|
264
373
|
* SINGLE-USE consumption via the consumption-store contract (store.js):
|
|
265
374
|
* `consume(key)` returns true the FIRST time, false on every replay, and marks
|
|
@@ -269,7 +378,8 @@ export function verifyBreakGlass(grantJson, { issuerKeys, now = Date.now, action
|
|
|
269
378
|
*
|
|
270
379
|
* Accepts the grant document ({ payload: { grant_id } }) or a verified result
|
|
271
380
|
* ({ grant_id }). NEVER throws — a missing store, missing grant_id, or a store
|
|
272
|
-
* error all refuse with a machine-readable reason.
|
|
381
|
+
* error all refuse with a machine-readable reason. This is a low-level primitive;
|
|
382
|
+
* only runBreakGlass also enforces store capabilities, evidence, and execution order.
|
|
273
383
|
*
|
|
274
384
|
* @param {object} grant break-glass grant document or verifyBreakGlass result
|
|
275
385
|
* @param {{ consume(key: string): Promise<boolean> }} store consumption store (store.js contract)
|
|
@@ -300,11 +410,11 @@ export async function consumeBreakGlass(grant, store) {
|
|
|
300
410
|
* taken under it. Append it via the hash-chained evidence log — with
|
|
301
411
|
* { strict: true } so a sink failure refuses the override.
|
|
302
412
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
413
|
+
* This builder does not execute or authorize anything. runBreakGlass is the
|
|
414
|
+
* enforcing path that requires a strict, independently validated record
|
|
415
|
+
* acknowledgement before invoking an effect. This function never throws, even
|
|
416
|
+
* for a malformed grant or missing decision; an uncanonicalizable artifact has
|
|
417
|
+
* a null grant_hash and cannot pass runBreakGlass verification.
|
|
308
418
|
*
|
|
309
419
|
* @param {object} grant the break-glass grant document as presented (even if invalid)
|
|
310
420
|
* @param {object} decision e.g. { allow, reason, action_type } from verify/consume
|
|
@@ -312,12 +422,19 @@ export async function consumeBreakGlass(grant, store) {
|
|
|
312
422
|
* @param {number|string|function} [o.now=Date.now] injected clock for the entry timestamp
|
|
313
423
|
* @returns {object} entry ready for createEvidenceLog().record()
|
|
314
424
|
*/
|
|
315
|
-
|
|
316
|
-
|
|
425
|
+
function buildBreakGlassEvidenceInternal(grant, decision, { now = Date.now } = {}) {
|
|
426
|
+
let nowMs;
|
|
427
|
+
try { nowMs = typeof now === 'function' ? toMs(now()) : toMs(now); } catch { nowMs = null; }
|
|
317
428
|
const p = (grant && typeof grant === 'object' && grant.payload && typeof grant.payload === 'object')
|
|
318
429
|
? grant.payload : null;
|
|
319
430
|
const sigs = (grant && Array.isArray(grant.signatures)) ? grant.signatures : [];
|
|
320
431
|
const d = (decision && typeof decision === 'object') ? decision : {};
|
|
432
|
+
let grantHash = null;
|
|
433
|
+
try { grantHash = sha256hex(canonical(grant ?? null)); } catch { /* uncanonicalizable artifacts stay unbound */ }
|
|
434
|
+
const principalIds = Array.isArray(d.signer_principal_ids)
|
|
435
|
+
&& d.signer_principal_ids.every(isNonEmptyString) ? d.signer_principal_ids.slice() : null;
|
|
436
|
+
const fingerprints = Array.isArray(d.signer_spki_fingerprints)
|
|
437
|
+
&& d.signer_spki_fingerprints.every(isNonEmptyString) ? d.signer_spki_fingerprints.slice() : null;
|
|
321
438
|
return {
|
|
322
439
|
kind: BREAKGLASS_EVIDENCE_KIND,
|
|
323
440
|
'@version': BREAKGLASS_VERSION,
|
|
@@ -327,10 +444,15 @@ export function buildBreakGlassEvidence(grant, decision, { now = Date.now } = {}
|
|
|
327
444
|
grant_reason: p?.reason ?? null,
|
|
328
445
|
scope: p?.scope?.action_types?.slice?.() ?? null,
|
|
329
446
|
threshold: p?.threshold ?? null,
|
|
447
|
+
policy_minimum_threshold: Number.isSafeInteger(d.policy_minimum_threshold)
|
|
448
|
+
? d.policy_minimum_threshold : null,
|
|
449
|
+
required_threshold: Number.isSafeInteger(d.required_threshold) ? d.required_threshold : null,
|
|
330
450
|
signer_kids: sigs.map((s) => s?.kid ?? null),
|
|
451
|
+
signer_principal_ids: principalIds,
|
|
452
|
+
signer_spki_fingerprints: fingerprints,
|
|
331
453
|
// Commits the log entry to the exact artifact presented — a later dispute
|
|
332
454
|
// can prove which grant (tampered or not) the decision was taken under.
|
|
333
|
-
grant_hash:
|
|
455
|
+
grant_hash: grantHash,
|
|
334
456
|
decision: {
|
|
335
457
|
allow: d.allow === true, // fail closed: anything else records a refusal
|
|
336
458
|
reason: isNonEmptyString(d.reason) ? d.reason : 'unspecified',
|
|
@@ -339,11 +461,123 @@ export function buildBreakGlassEvidence(grant, decision, { now = Date.now } = {}
|
|
|
339
461
|
};
|
|
340
462
|
}
|
|
341
463
|
|
|
464
|
+
export function buildBreakGlassEvidence(grant, decision, options = {}) {
|
|
465
|
+
try { return buildBreakGlassEvidenceInternal(grant, decision, options); } catch {
|
|
466
|
+
return {
|
|
467
|
+
kind: BREAKGLASS_EVIDENCE_KIND,
|
|
468
|
+
'@version': BREAKGLASS_VERSION,
|
|
469
|
+
at: new Date(0).toISOString(),
|
|
470
|
+
grant_id: null,
|
|
471
|
+
incident_ref: null,
|
|
472
|
+
grant_reason: null,
|
|
473
|
+
scope: null,
|
|
474
|
+
threshold: null,
|
|
475
|
+
policy_minimum_threshold: null,
|
|
476
|
+
required_threshold: null,
|
|
477
|
+
signer_kids: [],
|
|
478
|
+
signer_principal_ids: null,
|
|
479
|
+
signer_spki_fingerprints: null,
|
|
480
|
+
grant_hash: null,
|
|
481
|
+
decision: { allow: false, reason: 'unspecified', action_type: null },
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The sole high-level break-glass execution path. It snapshots the presented
|
|
488
|
+
* artifact, verifies it against relying-party policy, atomically consumes the
|
|
489
|
+
* grant in a capability-marked permanent store, validates a strict evidence
|
|
490
|
+
* acknowledgement, and only then invokes `effect`.
|
|
491
|
+
*/
|
|
492
|
+
export async function runBreakGlass({
|
|
493
|
+
grant,
|
|
494
|
+
policy,
|
|
495
|
+
issuerKeys,
|
|
496
|
+
actionType,
|
|
497
|
+
store,
|
|
498
|
+
evidence,
|
|
499
|
+
now = Date.now,
|
|
500
|
+
} = {}, effect) {
|
|
501
|
+
if (typeof effect !== 'function') throw new Error('runBreakGlass: effect function is required');
|
|
502
|
+
|
|
503
|
+
let snapshot;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = typeof grant === 'string' ? JSON.parse(grant) : grant;
|
|
506
|
+
snapshot = JSON.parse(canonical(parsed));
|
|
507
|
+
} catch {
|
|
508
|
+
const verification = verifyBreakGlass(grant, { policy, issuerKeys, actionType, now });
|
|
509
|
+
return { ok: false, reason: verification.reason, verification, consumption: null, evidence: null };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const verification = verifyBreakGlass(snapshot, { policy, issuerKeys, actionType, now });
|
|
513
|
+
if (!verification.valid) {
|
|
514
|
+
return { ok: false, reason: verification.reason, verification, consumption: null, evidence: null };
|
|
515
|
+
}
|
|
516
|
+
if (!isSecureConsumptionStore(store)) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
reason: 'secure_consumption_store_required',
|
|
520
|
+
verification,
|
|
521
|
+
consumption: null,
|
|
522
|
+
evidence: null,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
if (evidence?.strict !== true || typeof evidence?.record !== 'function') {
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
reason: 'strict_evidence_required',
|
|
529
|
+
verification,
|
|
530
|
+
consumption: null,
|
|
531
|
+
evidence: null,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const consume = store.consume.bind(store);
|
|
536
|
+
const record = evidence.record.bind(evidence);
|
|
537
|
+
const consumption = await consumeBreakGlass(verification, { consume });
|
|
538
|
+
if (!consumption.consumed) {
|
|
539
|
+
return { ok: false, reason: consumption.reason, verification, consumption, evidence: null };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const entry = buildBreakGlassEvidence(snapshot, {
|
|
543
|
+
...verification,
|
|
544
|
+
allow: true,
|
|
545
|
+
action_type: actionType,
|
|
546
|
+
}, { now });
|
|
547
|
+
let evidenceRecord;
|
|
548
|
+
try {
|
|
549
|
+
evidenceRecord = await record(entry);
|
|
550
|
+
if (!verifyEvidenceRecord(evidenceRecord, {
|
|
551
|
+
atomicRequired: evidence.atomicAppend === true,
|
|
552
|
+
expectedEntry: entry,
|
|
553
|
+
})) throw new Error('malformed evidence acknowledgement');
|
|
554
|
+
} catch {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
reason: 'evidence_record_failed',
|
|
558
|
+
verification,
|
|
559
|
+
consumption,
|
|
560
|
+
evidence: null,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const result = await effect({ verification, consumption, evidence: evidenceRecord });
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
reason: 'breakglass_executed',
|
|
568
|
+
result,
|
|
569
|
+
verification,
|
|
570
|
+
consumption,
|
|
571
|
+
evidence: evidenceRecord,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
342
575
|
export default {
|
|
343
576
|
mintBreakGlassAuthorization,
|
|
344
577
|
verifyBreakGlass,
|
|
345
578
|
consumeBreakGlass,
|
|
346
579
|
buildBreakGlassEvidence,
|
|
580
|
+
runBreakGlass,
|
|
347
581
|
BREAKGLASS_VERSION,
|
|
348
582
|
BREAKGLASS_EVIDENCE_KIND,
|
|
349
583
|
};
|