@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/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @emilia-protocol/gate — EMILIA Gate: the
|
|
2
|
+
* @emilia-protocol/gate — EMILIA Gate: the Consequence Firewall.
|
|
3
3
|
* @license Apache-2.0
|
|
4
4
|
*
|
|
5
5
|
* Deny-by-default enforcement for consequential machine actions. A guarded
|
|
@@ -25,26 +25,28 @@
|
|
|
25
25
|
* adds the three things a firewall needs over a bare verifier: assurance-tier
|
|
26
26
|
* enforcement, replay defense, and the evidence log. Fails closed.
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import {
|
|
29
30
|
verifyEmiliaReceipt,
|
|
30
31
|
receiptChallenge,
|
|
31
32
|
receiptRequiredHeader,
|
|
32
33
|
validateActionRiskManifest,
|
|
33
34
|
findActionRequirement,
|
|
34
35
|
evaluateReceiptAssurance,
|
|
35
|
-
|
|
36
|
+
validatePinnedQuorumPolicy,
|
|
37
|
+
receiptAssuranceTier as receiptAssuranceTierFromProof,
|
|
38
|
+
parseReceiptCarrier,
|
|
36
39
|
RECEIPT_REQUIRED_STATUS,
|
|
37
40
|
RECEIPT_REQUIRED_HEADER,
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
import { createAtomicEvidenceLog, createEvidenceLog, createMemoryAtomicEvidenceBackend } from './evidence.js';
|
|
41
|
+
} from '@emilia-protocol/require-receipt';
|
|
42
|
+
import { verifyWebAuthnSignoff, verifyQuorum } from '@emilia-protocol/verify';
|
|
43
|
+
import { MemoryConsumptionStore, isSecureConsumptionStore } from './store.js';
|
|
44
|
+
import {
|
|
45
|
+
canonicalEvidenceJson,
|
|
46
|
+
createAtomicEvidenceLog,
|
|
47
|
+
createEvidenceLog,
|
|
48
|
+
createMemoryAtomicEvidenceBackend,
|
|
49
|
+
} from './evidence.js';
|
|
48
50
|
import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
|
|
49
51
|
import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
|
|
50
52
|
import { buildReliancePacket, ADMISSIBILITY_VERDICTS } from './reliance-packet.js';
|
|
@@ -53,9 +55,29 @@ import { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
|
|
|
53
55
|
import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
54
56
|
import { classifyRetention, buildRetentionExport } from './retention.js';
|
|
55
57
|
import { createDefaultActionControlManifest, findActionControl, validateActionControlManifest } from './action-control-manifest.js';
|
|
58
|
+
import {
|
|
59
|
+
mintBreakGlassAuthorization,
|
|
60
|
+
verifyBreakGlass,
|
|
61
|
+
consumeBreakGlass,
|
|
62
|
+
buildBreakGlassEvidence,
|
|
63
|
+
runBreakGlass,
|
|
64
|
+
BREAKGLASS_VERSION,
|
|
65
|
+
BREAKGLASS_EVIDENCE_KIND,
|
|
66
|
+
} from './breakglass.js';
|
|
56
67
|
|
|
57
|
-
export {
|
|
58
|
-
|
|
68
|
+
export {
|
|
69
|
+
MemoryConsumptionStore,
|
|
70
|
+
canonicalEvidenceJson,
|
|
71
|
+
createEvidenceLog,
|
|
72
|
+
createAtomicEvidenceLog,
|
|
73
|
+
createMemoryAtomicEvidenceBackend,
|
|
74
|
+
};
|
|
75
|
+
export {
|
|
76
|
+
createDurableConsumptionStore,
|
|
77
|
+
createMemoryBackend,
|
|
78
|
+
isSecureConsumptionStore,
|
|
79
|
+
DURABLE_CONSUMPTION_VERSION,
|
|
80
|
+
} from './store.js';
|
|
59
81
|
export { createDurableChallengeStore, challengeStorageKey, challengeBodyDigest, DURABLE_CHALLENGE_STORE_VERSION } from './challenge-store.js';
|
|
60
82
|
export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
61
83
|
export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
|
|
@@ -86,17 +108,28 @@ export {
|
|
|
86
108
|
createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
|
|
87
109
|
} from './eg1-conformance.js';
|
|
88
110
|
export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
|
|
111
|
+
export {
|
|
112
|
+
mintBreakGlassAuthorization,
|
|
113
|
+
verifyBreakGlass,
|
|
114
|
+
consumeBreakGlass,
|
|
115
|
+
buildBreakGlassEvidence,
|
|
116
|
+
runBreakGlass,
|
|
117
|
+
BREAKGLASS_VERSION,
|
|
118
|
+
BREAKGLASS_EVIDENCE_KIND,
|
|
119
|
+
};
|
|
89
120
|
export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
|
|
90
121
|
const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
|
|
91
122
|
|
|
123
|
+
function safeCanonicalHash(value) {
|
|
124
|
+
try { return hashCanonical(value); } catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
92
127
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* the gate only confirms the packet answers the SAME profile_hash and carries an
|
|
99
|
-
* 'admissible' verdict. Pure, dependency-light, fail-closed.
|
|
128
|
+
* Structurally compare a PRE-COMPUTED admissibility block with a profile hash.
|
|
129
|
+
* This helper does NOT authenticate the block or establish evaluator provenance.
|
|
130
|
+
* An execution gate must first verify a signature over the packet or recompute
|
|
131
|
+
* the verdict from trusted evidence. createGate enforces that boundary through
|
|
132
|
+
* its verifyAdmissibilityPacket callback whenever a profile is pinned.
|
|
100
133
|
*
|
|
101
134
|
* @param {{id?:string, profile_hash:string}} pinned the profile the relying party requires
|
|
102
135
|
* @param {object|null} presented a reliance packet, or its `.admissibility` block,
|
|
@@ -156,13 +189,13 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
|
|
|
156
189
|
* strongest model — the verifier never trusts a key that travels inside the
|
|
157
190
|
* receipt. Delegated to require-receipt's receiptAssuranceTierFromProof.
|
|
158
191
|
*
|
|
159
|
-
* (b)
|
|
192
|
+
* (b) Embedded evidence (DoD audit fix): a full EP-QUORUM-v1
|
|
160
193
|
* document (payload.quorum) whose per-signer WebAuthn assertions verify via
|
|
161
194
|
* verifyQuorum (distinct humans + distinct keys + threshold + action-binding
|
|
162
195
|
* + window) earns `quorum`; a WebAuthn device signoff (payload.signoff =
|
|
163
196
|
* {context, webauthn}) that verifies against the approver's own key via
|
|
164
|
-
* verifyWebAuthnSignoff earns `class_a`.
|
|
165
|
-
*
|
|
197
|
+
* verifyWebAuthnSignoff earns `class_a`. Quorum additionally requires an
|
|
198
|
+
* out-of-band organizational policy and identity-bound approver directory.
|
|
166
199
|
*
|
|
167
200
|
* TRUST-LAUNDERING GUARD: an approver key carried INSIDE the receipt proves
|
|
168
201
|
* only that whoever minted the receipt also holds that key — it is NOT proof
|
|
@@ -170,8 +203,8 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
|
|
|
170
203
|
* key would collapse VERIFIED into ACCEPTED (any party can mint a fresh
|
|
171
204
|
* keypair, self-sign a signoff, and embed both). So path (b) elevates the
|
|
172
205
|
* tier ONLY when either: (i) the caller explicitly opts in with
|
|
173
|
-
* `allowEmbeddedApproverKeys:true`
|
|
174
|
-
* DEFAULT OFF); or (ii)
|
|
206
|
+
* `allowEmbeddedApproverKeys:true` for a single Class-A integrity demo
|
|
207
|
+
* (DEFAULT OFF); or (ii) the embedded approver key that would earn the
|
|
175
208
|
* credit is present in the relying party's PINNED approver key set
|
|
176
209
|
* (opts.approverKeys). With no pin and no opt-in, path (b) may still VERIFY
|
|
177
210
|
* the signoff/quorum, but it does NOT elevate above `software`. Fail-closed.
|
|
@@ -181,9 +214,9 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
|
|
|
181
214
|
* @param {object} [opts.approverKeys] pinned approver keys for path (a) and the
|
|
182
215
|
* path-(b) fallback: a receipt-embedded approver key elevates the tier only if
|
|
183
216
|
* it is one of these pinned keys (unless allowEmbeddedApproverKeys is set)
|
|
184
|
-
* @param {boolean} [opts.allowEmbeddedApproverKeys=false] explicit opt-in
|
|
185
|
-
*
|
|
186
|
-
*
|
|
217
|
+
* @param {boolean} [opts.allowEmbeddedApproverKeys=false] explicit opt-in where
|
|
218
|
+
* one unpinned embedded key may earn Class-A integrity. It never earns quorum.
|
|
219
|
+
* @param {object} [opts.quorumPolicy] relying-party-pinned organizational rule
|
|
187
220
|
* @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
|
|
188
221
|
* @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
|
|
189
222
|
* @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
|
|
@@ -202,13 +235,16 @@ export function receiptAssuranceTier(doc, opts = {}) {
|
|
|
202
235
|
|
|
203
236
|
// --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
|
|
204
237
|
const p = doc?.payload || {};
|
|
205
|
-
const verifyOpts =
|
|
238
|
+
const verifyOpts = {
|
|
239
|
+
...(opts.rpId ? { rpId: opts.rpId } : {}),
|
|
240
|
+
...(Array.isArray(opts.allowedOrigins) ? { allowedOrigins: opts.allowedOrigins } : {}),
|
|
241
|
+
};
|
|
206
242
|
// The relying party's PINNED approver public keys (base64url SPKI-DER strings).
|
|
207
243
|
// An embedded approver key elevates the tier only if it is in this set, unless
|
|
208
244
|
// the caller explicitly opts into the self-contained mode.
|
|
209
245
|
const allowEmbedded = opts.allowEmbeddedApproverKeys === true;
|
|
210
|
-
const
|
|
211
|
-
|
|
246
|
+
const keyIsTrusted = (k, approver) => allowEmbedded
|
|
247
|
+
|| Boolean(findPinnedApproverKey(opts.approverKeys, k, approver));
|
|
212
248
|
|
|
213
249
|
// quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
|
|
214
250
|
// under payload.quorum or payload.claim.quorum. It only counts if it is a full
|
|
@@ -219,12 +255,36 @@ export function receiptAssuranceTier(doc, opts = {}) {
|
|
|
219
255
|
// when every member's embedded approver key is pinned (or the caller opted in).
|
|
220
256
|
const q = p.quorum || p.claim?.quorum;
|
|
221
257
|
if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
258
|
+
const policy = validatePinnedQuorumPolicy(opts.quorumPolicy || opts.quorum_policy);
|
|
259
|
+
const trustedMembers = Array.isArray(q.members) ? q.members.map((member) => {
|
|
260
|
+
const entry = findPinnedApproverKey(
|
|
261
|
+
opts.approverKeys,
|
|
262
|
+
member?.approver_public_key,
|
|
263
|
+
member?.signoff?.context?.approver,
|
|
264
|
+
);
|
|
265
|
+
return entry ? { ...member, approver_public_key: entry.public_key } : null;
|
|
266
|
+
}) : [];
|
|
267
|
+
const membersTrusted = trustedMembers.length > 0 && trustedMembers.every(Boolean);
|
|
268
|
+
const qr = policy.ok && membersTrusted
|
|
269
|
+
? verifyQuorum({ ...q, policy: policy.policy, members: trustedMembers }, verifyOpts)
|
|
270
|
+
: { valid: false, checks: {} };
|
|
271
|
+
detail.quorum = {
|
|
272
|
+
valid: qr.valid,
|
|
273
|
+
checks: qr.checks,
|
|
274
|
+
policy_pinned: policy.ok,
|
|
275
|
+
embedded_keys_trusted: membersTrusted,
|
|
276
|
+
approvers: qr.valid
|
|
277
|
+
? trustedMembers.map((member) => member?.signoff?.context?.approver).filter(nonEmptyString)
|
|
278
|
+
: [],
|
|
279
|
+
roles: qr.valid
|
|
280
|
+
? trustedMembers.map((member) => ({
|
|
281
|
+
subject: member?.signoff?.context?.approver ?? null,
|
|
282
|
+
role: member?.role ?? null,
|
|
283
|
+
}))
|
|
284
|
+
: [],
|
|
285
|
+
refusal: !policy.ok ? policy.reason : (!membersTrusted ? 'quorum_member_key_unpinned' : null),
|
|
286
|
+
};
|
|
287
|
+
if (qr.valid && policy.ok && membersTrusted) detail.tier = 'quorum';
|
|
228
288
|
}
|
|
229
289
|
|
|
230
290
|
// class_a: a verifiable WebAuthn device signoff. The signoff evidence is
|
|
@@ -237,8 +297,13 @@ export function receiptAssuranceTier(doc, opts = {}) {
|
|
|
237
297
|
const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
|
|
238
298
|
if (key) {
|
|
239
299
|
const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
|
|
240
|
-
const trusted = keyIsTrusted(key);
|
|
241
|
-
detail.signoff = {
|
|
300
|
+
const trusted = keyIsTrusted(key, so?.context?.approver);
|
|
301
|
+
detail.signoff = {
|
|
302
|
+
valid: sr.valid,
|
|
303
|
+
checks: sr.checks,
|
|
304
|
+
embedded_key_trusted: trusted,
|
|
305
|
+
approver: so?.context?.approver ?? null,
|
|
306
|
+
};
|
|
242
307
|
if (sr.valid && trusted && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
|
|
243
308
|
}
|
|
244
309
|
}
|
|
@@ -254,25 +319,21 @@ export function receiptAssuranceTier(doc, opts = {}) {
|
|
|
254
319
|
* array/set of key strings. Used to decide whether a receipt-embedded approver
|
|
255
320
|
* key may elevate the path-(b) tier. Never throws.
|
|
256
321
|
*/
|
|
257
|
-
function
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (typeof pk === 'string' && pk) out.add(pk);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return out;
|
|
322
|
+
function spkiFingerprint(value) {
|
|
323
|
+
try {
|
|
324
|
+
const key = crypto.createPublicKey({ key: Buffer.from(value, 'base64url'), format: 'der', type: 'spki' });
|
|
325
|
+
return crypto.createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('hex');
|
|
326
|
+
} catch { return null; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function findPinnedApproverKey(approverKeys, presentedKey, approver) {
|
|
330
|
+
if (!approverKeys || typeof approverKeys !== 'object' || Array.isArray(approverKeys)) return null;
|
|
331
|
+
const presentedFingerprint = spkiFingerprint(presentedKey);
|
|
332
|
+
if (!presentedFingerprint || typeof approver !== 'string' || !approver) return null;
|
|
333
|
+
const matches = Object.values(approverKeys).filter((entry) => entry && typeof entry === 'object'
|
|
334
|
+
&& entry.approver_id === approver
|
|
335
|
+
&& spkiFingerprint(entry.public_key) === presentedFingerprint);
|
|
336
|
+
return matches.length === 1 ? matches[0] : null;
|
|
276
337
|
}
|
|
277
338
|
|
|
278
339
|
/** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
|
|
@@ -285,13 +346,207 @@ function isSignoffEvidence(s) {
|
|
|
285
346
|
return !!s && typeof s === 'object' && s.context && s.webauthn;
|
|
286
347
|
}
|
|
287
348
|
|
|
349
|
+
function nonEmptyString(value) {
|
|
350
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function businessAuthorizationSource(requirement) {
|
|
354
|
+
if (!requirement || typeof requirement !== 'object') return null;
|
|
355
|
+
const nested = requirement.business_authorization
|
|
356
|
+
|| requirement.businessAuthorization
|
|
357
|
+
|| requirement.authorization_requirement
|
|
358
|
+
|| requirement.authorizationRequirement
|
|
359
|
+
|| requirement.authorization_policy
|
|
360
|
+
|| requirement.authorizationPolicy
|
|
361
|
+
|| requirement.business_policy
|
|
362
|
+
|| requirement.control?.business_authorization
|
|
363
|
+
|| requirement.control?.authorization_requirement
|
|
364
|
+
|| requirement.control?.authorization_policy;
|
|
365
|
+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) return nested;
|
|
366
|
+
const hasDirect = [
|
|
367
|
+
'policy', 'business_policy', 'policy_id', 'policy_hash', 'tenant_id', 'allowed_approvers',
|
|
368
|
+
'allowed_approver_subjects', 'allowed_approver_roles',
|
|
369
|
+
].some((field) => Object.prototype.hasOwnProperty.call(requirement, field));
|
|
370
|
+
return hasDirect ? requirement : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Normalize the relying party's business-authorization pin for one action.
|
|
375
|
+
*
|
|
376
|
+
* Canonical manifest shape:
|
|
377
|
+
* business_authorization: {
|
|
378
|
+
* policy: { id, hash }, tenant_id,
|
|
379
|
+
* allowed_approvers: [{ subject, role }]
|
|
380
|
+
* }
|
|
381
|
+
*
|
|
382
|
+
* Flat policy_id/policy_hash and approver aliases are accepted so an existing
|
|
383
|
+
* manifest can add the control without changing its surrounding schema. Once
|
|
384
|
+
* any part is configured, every part is required; a partial pin is invalid.
|
|
385
|
+
*/
|
|
386
|
+
export function businessAuthorizationRequirement(requirement) {
|
|
387
|
+
const source = businessAuthorizationSource(requirement);
|
|
388
|
+
if (!source) {
|
|
389
|
+
return {
|
|
390
|
+
configured: false, ok: true, reason: null,
|
|
391
|
+
policy_id: null, policy_hash: null, tenant_id: null, allowed_approvers: [],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const policy = source.policy && typeof source.policy === 'object' && !Array.isArray(source.policy)
|
|
395
|
+
? source.policy
|
|
396
|
+
: (source.business_policy && typeof source.business_policy === 'object' && !Array.isArray(source.business_policy)
|
|
397
|
+
? source.business_policy : {});
|
|
398
|
+
const root = requirement && typeof requirement === 'object' ? requirement : {};
|
|
399
|
+
const policyId = nonEmptyString(source.policy_id ?? source.id ?? policy.policy_id ?? policy.id ?? root.policy_id);
|
|
400
|
+
const policyHash = nonEmptyString(source.policy_hash ?? source.hash ?? policy.policy_hash ?? policy.hash ?? root.policy_hash);
|
|
401
|
+
const tenantId = nonEmptyString(
|
|
402
|
+
source.tenant_id ?? source.tenant ?? source.organization_id
|
|
403
|
+
?? root.tenant_id ?? root.tenant ?? root.organization_id,
|
|
404
|
+
);
|
|
405
|
+
const rawApprovers = source.allowed_approvers ?? source.approvers ?? source.approver_roster
|
|
406
|
+
?? root.allowed_approvers ?? root.approvers ?? root.approver_roster;
|
|
407
|
+
const subjects = source.allowed_approver_subjects ?? source.approver_subjects
|
|
408
|
+
?? root.allowed_approver_subjects ?? root.approver_subjects;
|
|
409
|
+
const roles = source.allowed_approver_roles ?? source.approver_roles
|
|
410
|
+
?? root.allowed_approver_roles ?? root.approver_roles;
|
|
411
|
+
let allowed = [];
|
|
412
|
+
if (Array.isArray(rawApprovers)) {
|
|
413
|
+
allowed = rawApprovers.map((entry) => {
|
|
414
|
+
if (typeof entry === 'string') return { subject: entry, role: null };
|
|
415
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { subject: null, role: null };
|
|
416
|
+
return {
|
|
417
|
+
subject: nonEmptyString(entry.subject ?? entry.approver ?? entry.principal_id),
|
|
418
|
+
role: nonEmptyString(entry.role ?? entry.approver_role),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
} else if (Array.isArray(subjects)) {
|
|
422
|
+
const validRoles = Array.isArray(roles) ? roles.filter((role) => nonEmptyString(role)) : [];
|
|
423
|
+
allowed = subjects.flatMap((subject) => {
|
|
424
|
+
if (!validRoles.length) return [{ subject: nonEmptyString(subject), role: null }];
|
|
425
|
+
return validRoles.map((role) => ({ subject: nonEmptyString(subject), role }));
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const malformed = !policyId || !policyHash || !tenantId || allowed.length === 0
|
|
429
|
+
|| allowed.some((entry) => !entry.subject || !entry.role);
|
|
430
|
+
const duplicate = new Set(allowed.map((entry) => `${entry.subject}\u0000${entry.role}`)).size !== allowed.length;
|
|
431
|
+
return {
|
|
432
|
+
configured: true,
|
|
433
|
+
ok: !malformed && !duplicate,
|
|
434
|
+
reason: malformed ? 'business_authorization_incomplete'
|
|
435
|
+
: (duplicate ? 'business_authorization_duplicate_approver' : null),
|
|
436
|
+
policy_id: policyId,
|
|
437
|
+
policy_hash: policyHash,
|
|
438
|
+
tenant_id: tenantId,
|
|
439
|
+
allowed_approvers: allowed,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function signedString(candidates) {
|
|
444
|
+
const present = candidates.filter((value) => value !== undefined && value !== null);
|
|
445
|
+
if (!present.length) return { ok: true, value: null };
|
|
446
|
+
if (present.some((value) => !nonEmptyString(value))) return { ok: false, value: null };
|
|
447
|
+
const distinct = [...new Set(present)];
|
|
448
|
+
return distinct.length === 1 ? { ok: true, value: distinct[0] } : { ok: false, value: null };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function receiptRoleAssertions(receipt, tierResult) {
|
|
452
|
+
const claim = receipt?.payload?.claim || {};
|
|
453
|
+
const assertions = [];
|
|
454
|
+
const arrays = [claim.approvers, claim.approver_authorizations, claim.approver_roles];
|
|
455
|
+
for (const entries of arrays) {
|
|
456
|
+
if (!Array.isArray(entries)) continue;
|
|
457
|
+
for (const entry of entries) {
|
|
458
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
459
|
+
const subject = nonEmptyString(entry.subject ?? entry.approver ?? entry.principal_id);
|
|
460
|
+
const role = nonEmptyString(entry.role ?? entry.approver_role);
|
|
461
|
+
if (subject && role) assertions.push({ subject, role });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const singleSubject = nonEmptyString(claim.approver ?? claim.approver_subject);
|
|
465
|
+
const singleRole = nonEmptyString(claim.approver_role ?? claim.role);
|
|
466
|
+
if (singleSubject && singleRole) assertions.push({ subject: singleSubject, role: singleRole });
|
|
467
|
+
|
|
468
|
+
if (tierResult?.quorum?.valid === true) {
|
|
469
|
+
for (const member of (receipt?.payload?.quorum?.members || [])) {
|
|
470
|
+
const subject = nonEmptyString(member?.signoff?.context?.approver);
|
|
471
|
+
const role = nonEmptyString(member?.role);
|
|
472
|
+
if (subject && role) assertions.push({ subject, role });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return assertions;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function verifiedApproverSubjects(assurance, tierResult) {
|
|
479
|
+
const subjects = new Set(Array.isArray(assurance?.approvers) ? assurance.approvers.filter(nonEmptyString) : []);
|
|
480
|
+
if (tierResult?.signoff?.valid === true && nonEmptyString(tierResult.signoff.approver)) {
|
|
481
|
+
subjects.add(tierResult.signoff.approver);
|
|
482
|
+
}
|
|
483
|
+
if (tierResult?.quorum?.valid === true) {
|
|
484
|
+
for (const subject of (tierResult.quorum.approvers || [])) {
|
|
485
|
+
if (nonEmptyString(subject)) subjects.add(subject);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return [...subjects].sort();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Verify signed business-policy and tenant fields plus the cryptographically
|
|
493
|
+
* credited human approvers against one action's relying-party pins.
|
|
494
|
+
*/
|
|
495
|
+
export function verifyBusinessAuthorization({ requirement, receipt, assurance, tierResult } = {}) {
|
|
496
|
+
const expected = businessAuthorizationRequirement(requirement);
|
|
497
|
+
const claim = receipt?.payload?.claim || {};
|
|
498
|
+
const payload = receipt?.payload || {};
|
|
499
|
+
const policyId = signedString([claim.policy_id, payload.policy_id]);
|
|
500
|
+
const policyHash = signedString([claim.policy_hash, payload.policy_hash]);
|
|
501
|
+
const tenantId = signedString([
|
|
502
|
+
claim.tenant_id, claim.organization_id, payload.tenant_id, payload.organization_id,
|
|
503
|
+
]);
|
|
504
|
+
const subjects = verifiedApproverSubjects(assurance, tierResult);
|
|
505
|
+
const roleAssertions = receiptRoleAssertions(receipt, tierResult);
|
|
506
|
+
const evaluatedApprovers = subjects.map((subject) => {
|
|
507
|
+
const asserted = [...new Set(roleAssertions.filter((entry) => entry.subject === subject).map((entry) => entry.role))];
|
|
508
|
+
return { subject, roles: asserted };
|
|
509
|
+
});
|
|
510
|
+
const evaluated = {
|
|
511
|
+
policy_id: policyId.value,
|
|
512
|
+
policy_hash: policyHash.value,
|
|
513
|
+
tenant_id: tenantId.value,
|
|
514
|
+
approvers: evaluatedApprovers,
|
|
515
|
+
};
|
|
516
|
+
const base = { required: expected.configured, ok: true, reason: null, expected, evaluated };
|
|
517
|
+
if (!expected.configured) return base;
|
|
518
|
+
if (!expected.ok) return { ...base, ok: false, reason: expected.reason };
|
|
519
|
+
if (!policyId.ok) return { ...base, ok: false, reason: 'business_policy_id_ambiguous' };
|
|
520
|
+
if (!policyHash.ok) return { ...base, ok: false, reason: 'business_policy_hash_ambiguous' };
|
|
521
|
+
if (!tenantId.ok) return { ...base, ok: false, reason: 'business_tenant_ambiguous' };
|
|
522
|
+
if (policyId.value !== expected.policy_id) return { ...base, ok: false, reason: 'business_policy_id_mismatch' };
|
|
523
|
+
if (policyHash.value !== expected.policy_hash) return { ...base, ok: false, reason: 'business_policy_hash_mismatch' };
|
|
524
|
+
if (tenantId.value !== expected.tenant_id) return { ...base, ok: false, reason: 'business_tenant_mismatch' };
|
|
525
|
+
if (!subjects.length) return { ...base, ok: false, reason: 'business_approver_required' };
|
|
526
|
+
|
|
527
|
+
for (const approver of evaluatedApprovers) {
|
|
528
|
+
const allowedForSubject = expected.allowed_approvers.filter((entry) => entry.subject === approver.subject);
|
|
529
|
+
if (!allowedForSubject.length) return { ...base, ok: false, reason: 'business_approver_not_allowed' };
|
|
530
|
+
const allowedRoles = new Set(allowedForSubject.map((entry) => entry.role));
|
|
531
|
+
if (approver.roles.length && !approver.roles.some((role) => allowedRoles.has(role))) {
|
|
532
|
+
return { ...base, ok: false, reason: 'business_approver_role_not_allowed' };
|
|
533
|
+
}
|
|
534
|
+
// The manifest is the authoritative subject-to-role assignment. When the
|
|
535
|
+
// receipt does not repeat a role, record the pinned role rather than trusting
|
|
536
|
+
// an unsigned/free-text role label from elsewhere.
|
|
537
|
+
if (!approver.roles.length) approver.roles = [...allowedRoles].sort();
|
|
538
|
+
}
|
|
539
|
+
return base;
|
|
540
|
+
}
|
|
541
|
+
|
|
288
542
|
/**
|
|
289
543
|
* Create a gate.
|
|
290
544
|
* @param {object} opts
|
|
291
545
|
* @param {object} [opts.manifest] EP-ACTION-RISK-MANIFEST-v0.1 (which actions are guarded, their tier)
|
|
292
546
|
* @param {string[]} [opts.trustedKeys] base64url SPKI-DER issuer keys you trust
|
|
293
547
|
* @param {number} [opts.maxAgeSec=900] reject receipts older than this
|
|
294
|
-
* @param {object} [opts.store] consumption store
|
|
548
|
+
* @param {object} [opts.store] durable, ownership-fenced, permanent consumption store
|
|
549
|
+
* @param {boolean} [opts.allowEphemeralStore=false] explicit test/demo opt-in for in-memory state
|
|
295
550
|
* @param {object} [opts.log] evidence log (default in-memory, hash-chained)
|
|
296
551
|
* @param {boolean} [opts.allowInlineKey=false] accept the receipt's own key (integrity, NOT trust)
|
|
297
552
|
* @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
|
|
@@ -300,13 +555,19 @@ function isSignoffEvidence(s) {
|
|
|
300
555
|
* @param {object} [opts.approverKeys] PINNED approver keys ({ keyId: { public_key, key_class } }).
|
|
301
556
|
* Used both for the pinned assurance-proof path and to authorize receipt-embedded
|
|
302
557
|
* approver keys under the self-contained embedded-evidence path.
|
|
303
|
-
* @param {boolean} [opts.allowEmbeddedApproverKeys=false]
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
558
|
+
* @param {boolean} [opts.allowEmbeddedApproverKeys=false] allow one embedded key
|
|
559
|
+
* to earn Class-A integrity in demos. Embedded keys never establish quorum.
|
|
560
|
+
* @param {object} [opts.quorumPolicy] global relying-party-pinned quorum rule
|
|
561
|
+
* @param {object} [opts.quorumPolicies] action_type -> pinned quorum rule
|
|
562
|
+
* @param {string} [opts.rpId] WebAuthn relying-party identifier. Required for
|
|
563
|
+
* built-in Class-A or quorum assurance verification.
|
|
564
|
+
* @param {string[]} [opts.allowedOrigins] exact WebAuthn origins accepted by the
|
|
565
|
+
* relying party. Required for built-in Class-A or quorum verification.
|
|
566
|
+
* @param {function} [opts.verifyAdmissibilityPacket] trusted relying-party hook.
|
|
567
|
+
* Required whenever an admissibility profile is pinned. It must authenticate
|
|
568
|
+
* the presented packet or recompute the verdict and return the trusted block.
|
|
308
569
|
*/
|
|
309
|
-
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null, requiredAdmissibilityProfile = null, allowEmbeddedApproverKeys = false } = {}) {
|
|
570
|
+
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null, allowedOrigins = [], quorumPolicy = null, quorumPolicies = {}, requiredAdmissibilityProfile = null, verifyAdmissibilityPacket = null, allowEmbeddedApproverKeys = false } = {}) {
|
|
310
571
|
// Production key custody: a registry (rotation + revocation) supersedes a flat
|
|
311
572
|
// trustedKeys list. A flat list is coerced to an always-valid registry, so
|
|
312
573
|
// existing callers are unchanged.
|
|
@@ -314,23 +575,40 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
314
575
|
if (manifest) {
|
|
315
576
|
const m = validateActionRiskManifest(manifest);
|
|
316
577
|
if (!m.ok) throw new Error('EMILIA Gate: invalid action-risk manifest: ' + m.errors.join('; '));
|
|
578
|
+
for (const [index, actionRequirement] of (manifest.actions || []).entries()) {
|
|
579
|
+
if (actionRequirement?.receipt_required !== true) continue;
|
|
580
|
+
const business = businessAuthorizationRequirement(actionRequirement);
|
|
581
|
+
if (business.configured && !business.ok) {
|
|
582
|
+
throw new Error(`EMILIA Gate: invalid action-risk manifest: actions[${index}].business_authorization ${business.reason}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
317
585
|
}
|
|
318
586
|
if (allowInlineKey) {
|
|
319
587
|
// eslint-disable-next-line no-console
|
|
320
588
|
console.warn('EMILIA Gate: allowInlineKey=true accepts a receipt\'s OWN key. This proves INTEGRITY (the receipt was not tampered with) but NOT issuer TRUST (anyone can mint a receipt with their own key). Use for demos only; pin trustedKeys in production.');
|
|
321
589
|
}
|
|
322
|
-
// Replay defense is only sound if the
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// CLOSED in production unless the operator explicitly accepts a single instance.
|
|
590
|
+
// Replay defense is only sound if the store is shared, ownership-fenced, and
|
|
591
|
+
// permanent. This is a security property in every environment; NODE_ENV must
|
|
592
|
+
// never silently decide whether a receipt can be replayed.
|
|
326
593
|
let consumption = store;
|
|
327
594
|
if (!consumption) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
595
|
+
if (!allowEphemeralStore) throw new Error(
|
|
596
|
+
'EMILIA Gate requires a durable, ownership-fenced, permanent consumption store. '
|
|
597
|
+
+ 'Pass allowEphemeralStore:true only for an explicit test/demo gate.',
|
|
598
|
+
);
|
|
332
599
|
consumption = new MemoryConsumptionStore();
|
|
333
600
|
}
|
|
601
|
+
for (const method of ['consume', 'reserve', 'commit']) {
|
|
602
|
+
if (typeof consumption?.[method] !== 'function') {
|
|
603
|
+
throw new Error(`EMILIA Gate consumption store must implement ${method}()`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!allowEphemeralStore && !isSecureConsumptionStore(consumption)) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
'EMILIA Gate requires a durable, ownership-fenced, permanent consumption store. '
|
|
609
|
+
+ 'Pass allowEphemeralStore:true only for an explicit test/demo gate.',
|
|
610
|
+
);
|
|
611
|
+
}
|
|
334
612
|
const evidence = log || createEvidenceLog({ strict: strictEvidence });
|
|
335
613
|
|
|
336
614
|
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume', admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}) {
|
|
@@ -346,7 +624,18 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
346
624
|
const requiredTier = requirement
|
|
347
625
|
? requirement.assurance_class
|
|
348
626
|
: (selector.assurance_class || 'software');
|
|
627
|
+
const pinnedQuorumPolicy = requirement?.quorum_policy
|
|
628
|
+
|| (quorumPolicies && typeof quorumPolicies === 'object' ? quorumPolicies[action] : null)
|
|
629
|
+
|| quorumPolicy;
|
|
349
630
|
const observed = observedAction || selector.observedAction || selector.actionDetails || null;
|
|
631
|
+
const businessExpected = businessAuthorizationRequirement(requirement);
|
|
632
|
+
let businessEvaluation = {
|
|
633
|
+
required: businessExpected.configured,
|
|
634
|
+
ok: !businessExpected.configured,
|
|
635
|
+
reason: null,
|
|
636
|
+
expected: businessExpected,
|
|
637
|
+
evaluated: { policy_id: null, policy_hash: null, tenant_id: null, approvers: [] },
|
|
638
|
+
};
|
|
350
639
|
|
|
351
640
|
async function decide(allow, status, reason, extra = {}) {
|
|
352
641
|
const entry = {
|
|
@@ -360,7 +649,12 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
360
649
|
required_tier: requiredTier,
|
|
361
650
|
receipt_id: receipt?.payload?.receipt_id ?? null,
|
|
362
651
|
subject: receipt?.payload?.subject ?? null,
|
|
363
|
-
observed_action_hash: observed ?
|
|
652
|
+
observed_action_hash: observed ? safeCanonicalHash(observed) : null,
|
|
653
|
+
business_authorization: businessEvaluation,
|
|
654
|
+
evaluated_policy_id: businessEvaluation.evaluated.policy_id,
|
|
655
|
+
evaluated_policy_hash: businessEvaluation.evaluated.policy_hash,
|
|
656
|
+
evaluated_tenant_id: businessEvaluation.evaluated.tenant_id,
|
|
657
|
+
evaluated_approvers: businessEvaluation.evaluated.approvers,
|
|
364
658
|
...extra,
|
|
365
659
|
};
|
|
366
660
|
let record;
|
|
@@ -428,12 +722,38 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
428
722
|
// WebAuthn device signoff) re-verified via verifyQuorum /
|
|
429
723
|
// verifyWebAuthnSignoff (DoD audit fix).
|
|
430
724
|
// A receipt that only CLAIMS a higher tier earns 'software' and is refused.
|
|
725
|
+
const needRank = TIER_RANK[requiredTier];
|
|
726
|
+
if (needRank === undefined) {
|
|
727
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'unknown_required_tier', { have_tier: 'software', need_tier: requiredTier, assurance_tier_source: 'cryptographic_verification' });
|
|
728
|
+
}
|
|
729
|
+
if (needRank >= TIER_RANK.class_a && typeof verifyAssurance !== 'function'
|
|
730
|
+
&& (typeof rpId !== 'string' || !rpId
|
|
731
|
+
|| !Array.isArray(allowedOrigins) || allowedOrigins.length === 0
|
|
732
|
+
|| allowedOrigins.some((origin) => typeof origin !== 'string' || !origin))) {
|
|
733
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_context_unpinned', {
|
|
734
|
+
have_tier: 'software', need_tier: requiredTier,
|
|
735
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (requiredTier === 'quorum' && typeof verifyAssurance !== 'function') {
|
|
739
|
+
const policy = validatePinnedQuorumPolicy(pinnedQuorumPolicy);
|
|
740
|
+
if (!policy.ok) {
|
|
741
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, policy.reason, {
|
|
742
|
+
have_tier: 'software', need_tier: requiredTier,
|
|
743
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
431
747
|
const assurance = evaluateReceiptAssurance(receipt, requiredTier, {
|
|
432
748
|
approverKeys: approver_keys || approverKeys,
|
|
433
749
|
verifyAssurance,
|
|
750
|
+
rpId,
|
|
751
|
+
allowedOrigins,
|
|
752
|
+
quorumPolicy: pinnedQuorumPolicy,
|
|
434
753
|
});
|
|
435
754
|
const tierResult = receiptAssuranceTier(receipt, {
|
|
436
|
-
rpId, detail: true, approverKeys: approver_keys || approverKeys,
|
|
755
|
+
rpId, allowedOrigins, detail: true, approverKeys: approver_keys || approverKeys,
|
|
756
|
+
verifyAssurance, quorumPolicy: pinnedQuorumPolicy,
|
|
437
757
|
// Trust-laundering guard: a receipt-embedded approver key does NOT elevate
|
|
438
758
|
// the tier unless it is in the pinned approverKeys set, or the operator
|
|
439
759
|
// explicitly opted into the self-contained embedded-evidence mode. DEFAULT OFF.
|
|
@@ -442,13 +762,6 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
442
762
|
// Take the strongest tier either path proves.
|
|
443
763
|
const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
|
|
444
764
|
? assurance.have : tierResult.tier;
|
|
445
|
-
const needRank = TIER_RANK[requiredTier];
|
|
446
|
-
// Fail CLOSED on an unknown / mis-cased required tier: never silently treat
|
|
447
|
-
// it as 'software'. If a manifest asks for a tier this gate does not model,
|
|
448
|
-
// no receipt can satisfy it.
|
|
449
|
-
if (needRank === undefined) {
|
|
450
|
-
return decide(false, RECEIPT_REQUIRED_STATUS, 'unknown_required_tier', { have_tier: have, need_tier: requiredTier, assurance_tier_source: 'cryptographic_verification' });
|
|
451
|
-
}
|
|
452
765
|
if ((TIER_RANK[have] ?? 0) < needRank) {
|
|
453
766
|
// The credited tier (from either proof path) is below what the action
|
|
454
767
|
// requires. The canonical machine-readable reason is 'assurance_too_low';
|
|
@@ -461,6 +774,23 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
461
774
|
tier_evidence: { quorum: tierResult.quorum, signoff: tierResult.signoff },
|
|
462
775
|
});
|
|
463
776
|
}
|
|
777
|
+
// Business authorization is a distinct trust root from receipt issuer
|
|
778
|
+
// integrity and assurance tier. The signed claim must name the exact policy
|
|
779
|
+
// id+hash and tenant this action requirement pins, and the humans who
|
|
780
|
+
// cryptographically earned the tier must belong to its subject/role roster.
|
|
781
|
+
// This runs BEFORE execution binding, admissibility, and receipt reservation.
|
|
782
|
+
businessEvaluation = verifyBusinessAuthorization({
|
|
783
|
+
requirement,
|
|
784
|
+
receipt,
|
|
785
|
+
assurance,
|
|
786
|
+
tierResult,
|
|
787
|
+
});
|
|
788
|
+
if (!businessEvaluation.ok) {
|
|
789
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, businessEvaluation.reason, {
|
|
790
|
+
have_tier: have,
|
|
791
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
792
|
+
});
|
|
793
|
+
}
|
|
464
794
|
// The high-risk action packs define material fields that must be observed
|
|
465
795
|
// by the executor from the system of record. A signed, harmless-looking
|
|
466
796
|
// claim cannot authorize a different real mutation.
|
|
@@ -479,9 +809,32 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
479
809
|
// When no profile is pinned, this whole block is inert — behavior is
|
|
480
810
|
// byte-for-byte unchanged from the pre-admissibility gate.
|
|
481
811
|
const pinnedProfile = admissibilityProfile || selector.admissibilityProfile || requiredAdmissibilityProfile;
|
|
812
|
+
let trustedAdmissibility = null;
|
|
482
813
|
if (pinnedProfile) {
|
|
483
814
|
const presentedAdmissibility = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
|
|
484
|
-
|
|
815
|
+
if (typeof verifyAdmissibilityPacket !== 'function') {
|
|
816
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'admissibility_verifier_required', {
|
|
817
|
+
pinned_profile: { id: pinnedProfile.id ?? null, profile_hash: pinnedProfile.profile_hash ?? null },
|
|
818
|
+
have_tier: have,
|
|
819
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
trustedAdmissibility = await verifyAdmissibilityPacket({
|
|
824
|
+
pinned_profile: structuredClone(pinnedProfile),
|
|
825
|
+
presented: structuredClone(presentedAdmissibility),
|
|
826
|
+
receipt: structuredClone(receipt),
|
|
827
|
+
selector: structuredClone(selector),
|
|
828
|
+
observed_action: observed === null ? null : structuredClone(observed),
|
|
829
|
+
});
|
|
830
|
+
} catch {
|
|
831
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'admissibility_verification_failed', {
|
|
832
|
+
pinned_profile: { id: pinnedProfile.id ?? null, profile_hash: pinnedProfile.profile_hash ?? null },
|
|
833
|
+
have_tier: have,
|
|
834
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
const adm = verifyAdmissibilityAgainstPinnedProfile(pinnedProfile, trustedAdmissibility);
|
|
485
838
|
if (!adm.ok) {
|
|
486
839
|
return decide(false, RECEIPT_REQUIRED_STATUS, adm.reason, {
|
|
487
840
|
admissibility_check: adm,
|
|
@@ -517,7 +870,9 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
517
870
|
// Carry the admissibility block (from the presented packet) onto the decision
|
|
518
871
|
// so a reliance packet built from this decision embeds the verdict the relying
|
|
519
872
|
// party's evaluator computed. Only when something was actually presented.
|
|
520
|
-
const presentedAdmForAllow =
|
|
873
|
+
const presentedAdmForAllow = pinnedProfile
|
|
874
|
+
? trustedAdmissibility
|
|
875
|
+
: (admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null);
|
|
521
876
|
if (presentedAdmForAllow) {
|
|
522
877
|
const admBlock = presentedAdmForAllow.admissibility !== undefined ? presentedAdmForAllow.admissibility : presentedAdmForAllow;
|
|
523
878
|
if (admBlock) allowExtra.admissibility = admBlock;
|
|
@@ -525,27 +880,97 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
525
880
|
return decide(true, 200, 'allow', allowExtra);
|
|
526
881
|
}
|
|
527
882
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
883
|
+
async function requestGateInput(req, opts = {}) {
|
|
884
|
+
let selector = typeof opts.selector === 'function'
|
|
885
|
+
? await opts.selector(req)
|
|
886
|
+
: { ...(opts.selector || {}) };
|
|
887
|
+
if (!selector || typeof selector !== 'object' || Array.isArray(selector)) selector = {};
|
|
888
|
+
if (opts.action && !selector.action_type) {
|
|
889
|
+
selector.action_type = typeof opts.action === 'function' ? await opts.action(req) : opts.action;
|
|
890
|
+
}
|
|
891
|
+
let receipt = typeof opts.receipt === 'function' ? await opts.receipt(req) : (opts.receipt ?? null);
|
|
892
|
+
if (!receipt) {
|
|
536
893
|
const hdr = req.headers?.['x-emilia-receipt'];
|
|
537
|
-
if (hdr)
|
|
538
|
-
if (!
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
894
|
+
if (hdr) receipt = parseReceiptCarrier(hdr);
|
|
895
|
+
if (!receipt && req.body?.emilia_receipt) receipt = req.body.emilia_receipt;
|
|
896
|
+
}
|
|
897
|
+
const observedAction = typeof opts.observedAction === 'function'
|
|
898
|
+
? await opts.observedAction(req)
|
|
899
|
+
: (opts.observedAction || req.emiliaObservedAction || null);
|
|
900
|
+
const admissibilityProfile = typeof opts.admissibilityProfile === 'function'
|
|
901
|
+
? await opts.admissibilityProfile(req)
|
|
902
|
+
: (opts.admissibilityProfile ?? null);
|
|
903
|
+
const presentedPacket = typeof opts.reliancePacket === 'function'
|
|
904
|
+
? await opts.reliancePacket(req)
|
|
905
|
+
: (opts.reliancePacket ?? opts.admissibility ?? null);
|
|
906
|
+
return { selector, receipt, observedAction, admissibilityProfile, reliancePacket: presentedPacket };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function sendRefusal(res, authorization) {
|
|
910
|
+
if (typeof res?.setHeader === 'function' && authorization.header) {
|
|
911
|
+
res.setHeader(RECEIPT_REQUIRED_HEADER, authorization.header);
|
|
912
|
+
}
|
|
913
|
+
if (typeof res?.status === 'function' && typeof res?.json === 'function') {
|
|
914
|
+
return res.status(authorization.status).json(authorization.challenge);
|
|
915
|
+
}
|
|
916
|
+
if (res) res.statusCode = authorization.status;
|
|
917
|
+
if (typeof res?.end === 'function') {
|
|
918
|
+
if (typeof res?.setHeader === 'function') res.setHeader('content-type', 'application/json');
|
|
919
|
+
return res.end(JSON.stringify(authorization.challenge));
|
|
920
|
+
}
|
|
921
|
+
return authorization;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Express/Connect route wrapper. The route handler itself is the effect
|
|
926
|
+
* callback owned by gate.run(), so authorization, receipt reservation,
|
|
927
|
+
* execution, consumption, and evidence form one lifecycle.
|
|
928
|
+
*/
|
|
929
|
+
function route(handler, opts = {}) {
|
|
930
|
+
if (typeof handler !== 'function') throw new Error('EMILIA Gate route(): handler is required');
|
|
931
|
+
return async function emiliaGateRoute(req, res) {
|
|
932
|
+
const input = await requestGateInput(req, opts);
|
|
933
|
+
const out = await run(input, async (authorization) => {
|
|
934
|
+
req.emiliaGate = authorization;
|
|
935
|
+
return handler(req, res, authorization);
|
|
936
|
+
});
|
|
937
|
+
if (!out.ok) return sendRefusal(res, out.authorization);
|
|
938
|
+
req.emiliaGate = out.authorization;
|
|
939
|
+
req.emiliaGateExecution = out.execution;
|
|
940
|
+
req.emiliaReliancePacket = out.packet;
|
|
941
|
+
return out.result;
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* @deprecated Middleware cannot prove that code after next() actually ran.
|
|
947
|
+
* It therefore fails closed without parsing or consuming a presented receipt.
|
|
948
|
+
* Use gate.route(handler, opts), gate.guard(), or gate.run().
|
|
949
|
+
*/
|
|
950
|
+
function middleware(opts = {}) {
|
|
951
|
+
return async function emiliaGateDeprecatedMiddleware(req, res) {
|
|
952
|
+
const selector = typeof opts.selector === 'function'
|
|
953
|
+
? await opts.selector(req)
|
|
954
|
+
: { ...(opts.selector || {}) };
|
|
955
|
+
const action = (selector && (selector.action_type || selector.action))
|
|
956
|
+
|| (typeof opts.action === 'function' ? await opts.action(req) : opts.action)
|
|
957
|
+
|| null;
|
|
958
|
+
const reason = 'unsafe_middleware_deprecated';
|
|
959
|
+
const challenge = receiptChallenge(action, reason, {
|
|
960
|
+
status: RECEIPT_REQUIRED_STATUS,
|
|
961
|
+
assuranceClass: selector?.assurance_class || null,
|
|
962
|
+
maxAgeSec,
|
|
963
|
+
manifest: selector?.manifestUrl,
|
|
964
|
+
});
|
|
965
|
+
const authorization = {
|
|
966
|
+
allow: false,
|
|
967
|
+
status: RECEIPT_REQUIRED_STATUS,
|
|
968
|
+
reason,
|
|
969
|
+
action,
|
|
970
|
+
challenge,
|
|
971
|
+
header: receiptRequiredHeader({ action, assuranceClass: selector?.assurance_class || null, maxAgeSec }),
|
|
972
|
+
};
|
|
973
|
+
return sendRefusal(res, authorization);
|
|
549
974
|
};
|
|
550
975
|
}
|
|
551
976
|
|
|
@@ -564,7 +989,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
564
989
|
action: authorization?.action ?? auth.action ?? null,
|
|
565
990
|
receipt_id: auth.receipt_id ?? null,
|
|
566
991
|
outcome, // 'executed' | 'failed'
|
|
567
|
-
observed_action_hash: observedAction ?
|
|
992
|
+
observed_action_hash: observedAction ? safeCanonicalHash(observedAction) : null,
|
|
568
993
|
execution_binding: executionBinding || authorization?.evidence?.execution_binding || authorization?.execution_binding || null,
|
|
569
994
|
...(detail !== undefined ? { detail } : {}),
|
|
570
995
|
});
|
|
@@ -572,7 +997,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
572
997
|
|
|
573
998
|
/**
|
|
574
999
|
* Recommended end-to-end path. Reserves the receipt, runs the side effect,
|
|
575
|
-
* commits one-time consumption
|
|
1000
|
+
* commits one-time consumption after the effect attempt, and records execution.
|
|
576
1001
|
* Once the executor is invoked, an exception is an INDETERMINATE outcome: the
|
|
577
1002
|
* external effect may have happened before its response was lost. The receipt
|
|
578
1003
|
* is therefore committed (or left reserved if the store is unavailable),
|
|
@@ -598,7 +1023,8 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
598
1023
|
if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
|
|
599
1024
|
phase = 'recording_execution';
|
|
600
1025
|
const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
|
|
601
|
-
|
|
1026
|
+
const packet = await reliancePacket({ authorization, execution });
|
|
1027
|
+
return { ok: true, result, authorization, execution, packet };
|
|
602
1028
|
} catch (e) {
|
|
603
1029
|
// An exception after invoking fn() cannot establish that no external
|
|
604
1030
|
// effect occurred. Burn the approval if possible; if storage is down, the
|
|
@@ -663,7 +1089,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
663
1089
|
};
|
|
664
1090
|
}
|
|
665
1091
|
|
|
666
|
-
function reliancePacket({ authorization, execution = null, binding = null, admissibility = null } = {}) {
|
|
1092
|
+
async function reliancePacket({ authorization, execution = null, binding = null, admissibility = null } = {}) {
|
|
667
1093
|
// The admissibility block rides on the authorization decision's evidence when
|
|
668
1094
|
// a reliance packet was presented at check() time; an explicit `admissibility`
|
|
669
1095
|
// arg overrides it. buildReliancePacket fails closed on a non-'admissible'
|
|
@@ -692,7 +1118,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
692
1118
|
}
|
|
693
1119
|
|
|
694
1120
|
return {
|
|
695
|
-
check, run, recordExecution, middleware, guard, reliancePacket, evidence,
|
|
1121
|
+
check, run, recordExecution, route, wrapRoute: route, middleware, guard, reliancePacket, evidence,
|
|
696
1122
|
store: consumption, keyRegistry: registry, retention, retentionExport,
|
|
697
1123
|
};
|
|
698
1124
|
}
|
|
@@ -723,14 +1149,18 @@ export async function gateConformance({ gate, harness, action, selector = EG1_DE
|
|
|
723
1149
|
}
|
|
724
1150
|
|
|
725
1151
|
/**
|
|
726
|
-
* Self-certify the reference gate: build a default
|
|
1152
|
+
* Self-certify the reference gate: build a default Consequence Firewall that
|
|
727
1153
|
* trusts a fresh EG-1 harness key, then run all eight checks. This is the
|
|
728
1154
|
* canonical "EMILIA Gate earns EG-1" proof — runnable as a CLI (`eg1.mjs`),
|
|
729
1155
|
* shown on /gate, and the template an adopter copies for their integration.
|
|
730
1156
|
*/
|
|
731
1157
|
export async function gateConformanceSelfTest({ now } = {}) {
|
|
732
1158
|
const harness = createEg1Harness({ now });
|
|
733
|
-
const gate = createTrustedActionFirewall({
|
|
1159
|
+
const gate = createTrustedActionFirewall({
|
|
1160
|
+
trustedKeys: [harness.publicKey], approverKeys: harness.approverKeys,
|
|
1161
|
+
rpId: harness.rpId, allowedOrigins: harness.allowedOrigins,
|
|
1162
|
+
now, allowEphemeralStore: true,
|
|
1163
|
+
});
|
|
734
1164
|
return gateConformance({ gate, harness });
|
|
735
1165
|
}
|
|
736
1166
|
|
|
@@ -770,16 +1200,27 @@ export async function cf1Conformance({ gate, wrongGate, harness, manifest = null
|
|
|
770
1200
|
export async function cf1ConformanceSelfTest({ now } = {}) {
|
|
771
1201
|
const harness = createEg1Harness({ now });
|
|
772
1202
|
const manifest = createDefaultActionRiskManifest();
|
|
773
|
-
const gate = createTrustedActionFirewall({
|
|
1203
|
+
const gate = createTrustedActionFirewall({
|
|
1204
|
+
trustedKeys: [harness.publicKey], approverKeys: harness.approverKeys,
|
|
1205
|
+
rpId: harness.rpId, allowedOrigins: harness.allowedOrigins,
|
|
1206
|
+
now, allowEphemeralStore: true,
|
|
1207
|
+
});
|
|
774
1208
|
const wrongHarness = createEg1Harness({ now });
|
|
775
|
-
const wrongGate = createTrustedActionFirewall({
|
|
1209
|
+
const wrongGate = createTrustedActionFirewall({
|
|
1210
|
+
trustedKeys: [wrongHarness.publicKey], approverKeys: wrongHarness.approverKeys,
|
|
1211
|
+
rpId: wrongHarness.rpId, allowedOrigins: wrongHarness.allowedOrigins,
|
|
1212
|
+
now, allowEphemeralStore: true,
|
|
1213
|
+
});
|
|
776
1214
|
return cf1Conformance({ gate, wrongGate, harness, manifest, selector: EG1_DEFAULT_SELECTOR, action: harness.action });
|
|
777
1215
|
}
|
|
778
1216
|
|
|
779
1217
|
export default {
|
|
780
1218
|
createGate,
|
|
781
1219
|
createTrustedActionFirewall,
|
|
1220
|
+
runBreakGlass,
|
|
782
1221
|
receiptAssuranceTier,
|
|
1222
|
+
businessAuthorizationRequirement,
|
|
1223
|
+
verifyBusinessAuthorization,
|
|
783
1224
|
verifyAdmissibilityAgainstPinnedProfile,
|
|
784
1225
|
ADMISSIBILITY_VERDICTS,
|
|
785
1226
|
MemoryConsumptionStore,
|