@emilia-protocol/gate 0.9.5 → 0.11.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/LICENSE +190 -0
  3. package/README.md +95 -16
  4. package/action-control-manifest.js +26 -0
  5. package/action-escrow-custodian.js +395 -0
  6. package/action-escrow-evidence.js +859 -0
  7. package/action-escrow-package.js +1669 -0
  8. package/action-escrow-postgres.js +338 -0
  9. package/action-escrow-state.js +397 -0
  10. package/action-escrow-verifiers.js +332 -0
  11. package/action-escrow.js +2448 -0
  12. package/adapters/_kit.js +47 -5
  13. package/adapters/aws.js +15 -4
  14. package/adapters/github-demo.mjs +25 -22
  15. package/adapters/github.js +1 -1
  16. package/adapters/jira.js +1 -0
  17. package/adapters/linear.js +1 -0
  18. package/adapters/salesforce.js +1 -0
  19. package/adapters/stripe.js +1 -1
  20. package/adapters/supabase.js +26 -4
  21. package/adapters/vercel.js +33 -4
  22. package/aec-execution.js +1 -3
  23. package/breakglass.js +285 -51
  24. package/control-plane.js +341 -0
  25. package/coverage.js +722 -0
  26. package/custody-demo.mjs +13 -3
  27. package/demo.mjs +9 -3
  28. package/deploy/helm/README.md +16 -8
  29. package/deploy/helm/emilia-gate/Chart.yaml +3 -1
  30. package/deploy/helm/emilia-gate/templates/NOTES.txt +3 -2
  31. package/deploy/helm/emilia-gate/templates/deployment.yaml +55 -1
  32. package/deploy/helm/emilia-gate/values.yaml +18 -2
  33. package/deploy/helm/emilia-gate-service/Chart.yaml +11 -0
  34. package/deploy/helm/emilia-gate-service/README.md +81 -0
  35. package/deploy/helm/emilia-gate-service/templates/NOTES.txt +12 -0
  36. package/deploy/helm/emilia-gate-service/templates/_helpers.tpl +83 -0
  37. package/deploy/helm/emilia-gate-service/templates/deployment.yaml +153 -0
  38. package/deploy/helm/emilia-gate-service/templates/migration-job.yaml +73 -0
  39. package/deploy/helm/emilia-gate-service/templates/networkpolicy.yaml +113 -0
  40. package/deploy/helm/emilia-gate-service/templates/pdb.yaml +14 -0
  41. package/deploy/helm/emilia-gate-service/templates/service.yaml +20 -0
  42. package/deploy/helm/emilia-gate-service/templates/serviceaccount.yaml +8 -0
  43. package/deploy/helm/emilia-gate-service/tests/fixtures/001_gate.sql +26 -0
  44. package/deploy/helm/emilia-gate-service/tests/fixtures/002_runtime_access.sql +32 -0
  45. package/deploy/helm/emilia-gate-service/tests/fixtures/gate.config.mjs +29 -0
  46. package/deploy/helm/emilia-gate-service/tests/render-check.sh +145 -0
  47. package/deploy/helm/emilia-gate-service/values.schema.json +145 -0
  48. package/deploy/helm/emilia-gate-service/values.yaml +166 -0
  49. package/deploy/sql/001-runtime.sql +707 -0
  50. package/deploy/terraform/README.md +24 -14
  51. package/deploy/terraform/main.tf +59 -0
  52. package/deploy/terraform/service/README.md +81 -0
  53. package/deploy/terraform/service/main.tf +718 -0
  54. package/deploy/terraform/service/outputs.tf +19 -0
  55. package/deploy/terraform/service/tests/validate.sh +24 -0
  56. package/deploy/terraform/service/tests/validation.tftest.hcl +168 -0
  57. package/deploy/terraform/service/variables.tf +459 -0
  58. package/deploy/terraform/service/versions.tf +10 -0
  59. package/deploy/terraform/variables.tf +95 -2
  60. package/deployment-attestation.js +248 -0
  61. package/eg1-conformance.js +46 -12
  62. package/enterprise.js +6 -2
  63. package/ep-assure.mjs +3 -2
  64. package/evidence-postgres.js +357 -0
  65. package/evidence.js +10 -3
  66. package/execution-binding.js +141 -26
  67. package/index.js +556 -115
  68. package/key-registry.js +51 -19
  69. package/mcp.js +4 -2
  70. package/network-witness.js +500 -0
  71. package/package.json +39 -5
  72. package/reliance-kernel.js +5 -6
  73. package/reliance-packet.js +43 -10
  74. package/reports/assurance-package.js +45 -19
  75. package/reports/reperform.js +78 -18
  76. package/reports/underwriter.js +1 -1
  77. package/settlement.js +300 -0
  78. package/store-postgres.js +14 -0
  79. package/store.js +26 -5
  80. package/strict-json.js +103 -0
  81. 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: 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.
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 principal can never pre-fill two threshold slots.
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 < 1) {
116
- throw new Error('breakglass: threshold must be an integer >= 1');
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 issuer keys. NEVER throws for a
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 | duplicate_signer |
167
- * threshold_unmet | unknown_kid | bad_signature |
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 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
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 {object|Array<{kid:string,key:string}>} o.issuerKeys pinned kid -> base64url SPKI-DER public key
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
- export function verifyBreakGlass(grantJson, { issuerKeys, now = Date.now, actionType } = {}) {
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 { doc = JSON.parse(doc); } catch { return refuse('grant_unparseable'); }
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 principals: one kid may fill exactly one threshold slot.
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 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;
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
- 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 });
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
- const nowMs = typeof now === 'function' ? now() : toMs(now);
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
- * 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).
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
- export function buildBreakGlassEvidence(grant, decision, { now = Date.now } = {}) {
316
- const nowMs = typeof now === 'function' ? now() : toMs(now);
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: sha256hex(canonical(grant ?? null)),
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
  };