@emilia-protocol/gate 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,13 @@ Each pack also defines `execution_binding.required_fields`. The executor must pa
75
75
  fields from the real system of record. If the signed claim and observed mutation differ, the gate
76
76
  refuses with `execution_binding_failed` before consuming the receipt.
77
77
 
78
+ Execution-parameter binding is therefore a **Gate** guarantee that holds **only when you supply a
79
+ system-of-record `observedAction`**: it is the Gate — not receipt verification on its own — that
80
+ proves the executed parameters matched what was authorized. If a required field is declared but no
81
+ `observedAction` is provided, the check fails closed (`execution_binding_failed`), never silently
82
+ passes. A bare `@emilia-protocol/require-receipt` gate binds the action type/target only; reach for
83
+ this package when parameter drift (amount, beneficiary, commit, role, …) must be caught.
84
+
78
85
  Prefer `gate.run(...)` for mutations: it reserves the receipt, runs the side effect, commits
79
86
  one-time consumption only after success, releases the reservation if the action fails before
80
87
  mutation, and emits the execution receipt + reliance packet. Use lower-level `gate.check(...)` only
package/demo.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * @license Apache-2.0
6
6
  */
7
7
  import crypto from 'node:crypto';
8
- import { createTrustedActionFirewall } from './index.js';
8
+ import { createTrustedActionFirewall, mintDeviceSignoff } from './index.js';
9
9
 
10
10
  const canon = (v) => v == null ? JSON.stringify(v)
11
11
  : Array.isArray(v) ? `[${v.map(canon).join(',')}]`
@@ -21,8 +21,17 @@ const action = {
21
21
  payment_instruction_id: 'pi_demo_40000',
22
22
  beneficiary_account_hash: 'sha256:demo-beneficiary',
23
23
  };
24
+ // action_hash the CFO's device assertion is bound to.
25
+ const actionHash = crypto.createHash('sha256').update(canon(action), 'utf8').digest('hex');
24
26
  const receipt = (outcome, extra = {}) => {
25
27
  const payload = { receipt_id: `rcpt_${++n}`, subject: 'agent:finance-bot', issuer: 'ep:org:demo', created_at: new Date().toISOString(), claim: { ...action, outcome, approver: 'ep:approver:cfo', ...extra } };
28
+ // class_a is EARNED by a genuine WebAuthn device signoff, not by the outcome
29
+ // string. A software receipt (outcome 'allow') carries no signoff.
30
+ if (outcome === 'allow_with_signoff') {
31
+ const s = mintDeviceSignoff({ actionHash, approver: 'ep:approver:cfo' });
32
+ payload.signoff = s.signoff;
33
+ payload.approver_public_key = s.approver_public_key;
34
+ }
26
35
  return { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value: crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url') } };
27
36
  };
28
37
 
@@ -43,6 +43,80 @@ const canon = (v) => (v == null ? JSON.stringify(v)
43
43
  const sha256Hex = (v) => crypto.createHash('sha256').update(v, 'utf8').digest('hex');
44
44
  const sha256Bytes = (v) => crypto.createHash('sha256').update(v).digest();
45
45
 
46
+ const RP_ID = 'emiliaprotocol.ai';
47
+
48
+ /**
49
+ * Mint a GENUINE WebAuthn ECDSA-P256 device signoff over an authorization
50
+ * context — the same structure @emilia-protocol/verify verifyWebAuthnSignoff
51
+ * checks. This is what earns a receipt its class_a tier: a real per-signer
52
+ * assertion, not a self-asserted `outcome` string. Used to build the Class-A and
53
+ * quorum evidence the EG-1 harness embeds so the Gate can CRYPTOGRAPHICALLY
54
+ * credit the tier.
55
+ */
56
+ export function mintDeviceSignoff({ actionHash, approver, issuedAtMs = Date.now(), nonce, prevContextHash = undefined } = {}) {
57
+ const signer = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
58
+ const context = {
59
+ ep_version: '1.0', context_type: 'ep.signoff.v1',
60
+ action_hash: actionHash,
61
+ policy: 'policy_eg1',
62
+ nonce: nonce || ('sig_' + crypto.randomBytes(16).toString('hex')),
63
+ approver,
64
+ initiator: 'ent_agent_eg1',
65
+ issued_at: new Date(issuedAtMs).toISOString(),
66
+ expires_at: new Date(issuedAtMs + 5 * 60_000).toISOString(),
67
+ ...(prevContextHash !== undefined ? { prev_context_hash: prevContextHash } : {}),
68
+ };
69
+ const challenge = crypto.createHash('sha256').update(canon(context), 'utf8').digest().toString('base64url');
70
+ const clientData = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: `https://www.${RP_ID}` }), 'utf8');
71
+ const authData = Buffer.concat([
72
+ crypto.createHash('sha256').update(RP_ID, 'utf8').digest(),
73
+ Buffer.from([0x05]), // UP | UV
74
+ Buffer.from([0, 0, 0, 1]),
75
+ ]);
76
+ const signed = Buffer.concat([authData, crypto.createHash('sha256').update(clientData).digest()]);
77
+ const signature = crypto.sign('sha256', signed, signer.privateKey).toString('base64url');
78
+ return {
79
+ signoff: {
80
+ '@type': 'ep.signoff',
81
+ context,
82
+ webauthn: {
83
+ authenticator_data: authData.toString('base64url'),
84
+ client_data_json: clientData.toString('base64url'),
85
+ signature,
86
+ },
87
+ approver_public_key: signer.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
88
+ },
89
+ approver_public_key: signer.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
90
+ context,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Mint a GENUINE EP-QUORUM-v1 evidence document: N distinct humans, each on a
96
+ * distinct device key, each with a real WebAuthn assertion bound to the SAME
97
+ * action_hash, within a window. verifyQuorum returns valid for it. This is what
98
+ * earns a receipt its `quorum` tier — never a bare {signers,threshold} block.
99
+ */
100
+ export function mintQuorumEvidence({ actionHash, threshold = 2, approvers, issuedAtMs = Date.now() } = {}) {
101
+ const people = approvers || Array.from({ length: threshold }, (_, i) => ({ role: `approver_${i + 1}`, approver: `ep:approver:eg1_${i + 1}` }));
102
+ const members = people.map((p, i) => {
103
+ const s = mintDeviceSignoff({ actionHash, approver: p.approver, issuedAtMs: issuedAtMs + i * 1000 });
104
+ return { role: p.role, approver_public_key: s.approver_public_key, signoff: { '@type': s.signoff['@type'], context: s.signoff.context, webauthn: s.signoff.webauthn } };
105
+ });
106
+ return {
107
+ '@type': 'ep.quorum',
108
+ action_hash: actionHash,
109
+ policy: {
110
+ mode: 'threshold',
111
+ required: threshold,
112
+ approvers: people,
113
+ distinct_humans: true,
114
+ window_sec: 900,
115
+ },
116
+ members,
117
+ };
118
+ }
119
+
46
120
  // The default high-risk action EG-1 exercises: a Class-A money movement, which
47
121
  // the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
48
122
  export const EG1_DEFAULT_SELECTOR = Object.freeze({ protocol: 'mcp', tool: 'release_payment' });
@@ -87,6 +161,10 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
87
161
  let counter = 0;
88
162
  const nowMs = () => (typeof now === 'function' ? now() : now);
89
163
 
164
+ // The action_hash the self-contained human device assertions are bound to.
165
+ // Derived from the action so the signoff/quorum evidence is about THIS action.
166
+ const actionHash = crypto.createHash('sha256').update(canon(action), 'utf8').digest('hex');
167
+
90
168
  function assuranceContext(payload) {
91
169
  return {
92
170
  '@version': 'EP-ASSURANCE-CONTEXT-v1',
@@ -140,26 +218,46 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
140
218
  /**
141
219
  * Mint a scenario receipt.
142
220
  * @param {object} o
143
- * @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software, 'allow_with_signoff'=Class-A
144
- * @param {object} [o.quorum] a quorum block (signers/threshold) -> quorum tier
221
+ * @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software; 'allow_with_signoff'
222
+ * embeds a REAL WebAuthn device signoff so the receipt cryptographically earns class_a.
223
+ * @param {object|boolean} [o.quorum] request quorum-tier evidence. Truthy -> a REAL
224
+ * EP-QUORUM-v1 doc (distinct humans + distinct keys + per-signer assertions). If an
225
+ * object with `threshold`/`signers`, its size sets the quorum size.
226
+ * @param {boolean} [o.fakeQuorum] embed an UNVERIFIABLE self-asserted quorum block
227
+ * ({signers,threshold}) with no per-signer signatures — used to prove the Gate
228
+ * REFUSES it (must NOT be credited quorum). For adversarial tests only.
145
229
  * @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
146
230
  */
147
- function mint({ outcome = 'allow_with_signoff', quorum = null, tamper = null, extra = {} } = {}) {
231
+ function mint({ outcome = 'allow_with_signoff', quorum = null, fakeQuorum = false, tamper = null, extra = {} } = {}) {
232
+ const claim = { ...action, outcome, approver: 'ep:approver:eg1', ...extra };
148
233
  const payload = {
149
234
  receipt_id: `${idPrefix}_${++counter}`,
150
235
  subject: 'agent:eg1-conformance',
151
236
  issuer: 'ep:org:eg1',
152
237
  created_at: new Date(nowMs()).toISOString(),
153
- claim: {
154
- ...action,
155
- outcome,
156
- approver: 'ep:approver:eg1',
157
- ...(quorum ? { quorum } : {}),
158
- ...extra,
159
- },
238
+ claim,
160
239
  };
161
- if (outcome === 'allow_with_signoff' || quorum) {
240
+ if (fakeQuorum) {
241
+ // Self-asserted ONLY — NO members / NO signatures / NO pinned proof. The
242
+ // Gate must refuse to credit this as quorum (assurance_too_low). For
243
+ // adversarial tests that prove payload claims are never trusted.
244
+ payload.quorum = { signers: ['ep:a', 'ep:b'], threshold: 2 };
245
+ } else if (outcome === 'allow_with_signoff' || quorum) {
246
+ // Genuine, per-signer-verifiable evidence. The PRIMARY proof is the pinned
247
+ // EP-ASSURANCE-PROOF-v1 (verified against the harness's pinned approverKeys),
248
+ // which is what the EG-1 gate/custody path checks. We ALSO embed self-contained
249
+ // evidence (EP-QUORUM-v1 / WebAuthn device signoff) so a relying party that
250
+ // does NOT pin keys can still cryptographically credit the tier (DoD audit fix).
162
251
  payload.assurance_proof = assuranceProof(payload, quorum);
252
+ if (quorum) {
253
+ const threshold = Number.isInteger(quorum.threshold) ? quorum.threshold
254
+ : (Array.isArray(quorum.signers) ? quorum.signers.length : 2);
255
+ payload.quorum = mintQuorumEvidence({ actionHash, threshold, issuedAtMs: nowMs() });
256
+ } else {
257
+ const s = mintDeviceSignoff({ actionHash, approver: 'ep:approver:eg1', issuedAtMs: nowMs() });
258
+ payload.signoff = s.signoff;
259
+ payload.approver_public_key = s.approver_public_key;
260
+ }
163
261
  }
164
262
  const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
165
263
  const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
@@ -167,7 +265,7 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
167
265
  return receipt;
168
266
  }
169
267
 
170
- return { publicKey: pub, approverKeys, mint, action, now: nowMs };
268
+ return { publicKey: pub, approverKeys, mint, action, actionHash, now: nowMs };
171
269
  }
172
270
 
173
271
  /**
@@ -270,4 +368,4 @@ export async function runEg1({ invoke, harness, action } = {}) {
270
368
  };
271
369
  }
272
370
 
273
- export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1 };
371
+ export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence };
package/index.js CHANGED
@@ -6,8 +6,12 @@
6
6
  * action runs ONLY if it arrives with a receipt that is:
7
7
  * 1. valid — Ed25519 over canonical JSON, signed by a pinned issuer;
8
8
  * 2. in-scope — bound to the exact action the manifest guards;
9
- * 3. sufficiently — meets the action's required assurance tier
10
- * assured (software / class_a device signoff / quorum);
9
+ * 3. sufficiently — meets the action's required assurance tier, and the
10
+ * assured credited tier is CRYPTOGRAPHICALLY VERIFIED, not read
11
+ * from self-asserted payload fields: class_a requires a
12
+ * valid WebAuthn device signoff, quorum requires a valid
13
+ * EP-QUORUM-v1 (distinct humans + distinct keys +
14
+ * threshold + per-signer assertions);
11
15
  * 4. fresh — within max age; and
12
16
  * 5. unused — not a replay (one-time consumption).
13
17
  * Otherwise it is refused with a machine-readable Receipt-Required challenge
@@ -32,12 +36,19 @@ const {
32
36
  RECEIPT_REQUIRED_STATUS,
33
37
  RECEIPT_REQUIRED_HEADER,
34
38
  } = await import('@emilia-protocol/require-receipt').catch(() => import('../require-receipt/index.js'));
39
+ // The real per-signer verifiers (WebAuthn device-signoff + M-of-N quorum). The
40
+ // Gate MUST use these to CREDIT class_a / quorum — a receipt's self-asserted
41
+ // outcome string or a fabricated quorum block is NOT proof. Same resolution
42
+ // pattern as require-receipt: prefer the published package, fall back to the
43
+ // in-repo source so the monorepo test/build works without a node_modules link.
44
+ const { verifyWebAuthnSignoff, verifyQuorum } = await import('@emilia-protocol/verify')
45
+ .catch(() => import('../verify/index.js'));
35
46
  import { MemoryConsumptionStore } from './store.js';
36
47
  import { createEvidenceLog } from './evidence.js';
37
48
  import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
38
49
  import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
39
50
  import { buildReliancePacket } from './reliance-packet.js';
40
- import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
51
+ import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR, mintDeviceSignoff, mintQuorumEvidence } from './eg1-conformance.js';
41
52
  import { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
42
53
  import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
43
54
  import { classifyRetention, buildRetentionExport } from './retention.js';
@@ -64,18 +75,99 @@ export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsF
64
75
  export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
65
76
  export {
66
77
  EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
67
- createEg1Harness, makeGateInvoke, runEg1,
78
+ createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
68
79
  } from './eg1-conformance.js';
69
80
  export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
70
81
  export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
71
82
  const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
72
83
 
73
84
  /**
74
- * Return the proof-backed assurance tier. Without pinned approver keys or a
75
- * caller-supplied verifier, higher tiers are not inferred from receipt fields.
85
+ * The assurance tier a receipt has CRYPTOGRAPHICALLY EARNED.
86
+ *
87
+ * SECURITY: the credited tier is NEVER inferred from self-asserted payload
88
+ * fields. A bare `quorum:{signers,threshold}` block or an `outcome:
89
+ * 'allow_with_signoff'` string with no verifiable signature earns only
90
+ * `software` — it will be refused `assurance_too_low` by any guard that needs
91
+ * more. Fail-closed by construction.
92
+ *
93
+ * Two independent cryptographic proof shapes are accepted; a receipt earns the
94
+ * HIGHEST tier any of them proves:
95
+ *
96
+ * (a) Pinned assurance proof (`payload.assurance_proof`, EP-ASSURANCE-PROOF-v1):
97
+ * per-signer signatures verified against PINNED approver keys (opts.approverKeys)
98
+ * or a caller-supplied verifier (opts.verifyAssurance). This is the primary,
99
+ * strongest model — the verifier never trusts a key that travels inside the
100
+ * receipt. Delegated to require-receipt's receiptAssuranceTierFromProof.
101
+ *
102
+ * (b) Self-contained embedded evidence (DoD audit fix): a full EP-QUORUM-v1
103
+ * document (payload.quorum) whose per-signer WebAuthn assertions verify via
104
+ * verifyQuorum (distinct humans + distinct keys + threshold + action-binding
105
+ * + window) earns `quorum`; a WebAuthn device signoff (payload.signoff =
106
+ * {context, webauthn}) that verifies against the approver's own key via
107
+ * verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
108
+ * with the receipt rather than being pinned by the relying party.
109
+ *
110
+ * @param {object} doc the EP-RECEIPT-v1 document
111
+ * @param {object} [opts]
112
+ * @param {object} [opts.approverKeys] pinned approver keys for path (a)
113
+ * @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
114
+ * @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
115
+ * @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
116
+ * @returns {'software'|'class_a'|'quorum'|object} the highest tier proven
76
117
  */
77
118
  export function receiptAssuranceTier(doc, opts = {}) {
78
- return receiptAssuranceTierFromProof(doc, opts);
119
+ const detail = { tier: 'software', quorum: null, signoff: null };
120
+
121
+ // --- Path (a): pinned assurance proof / caller-supplied verifier. ---
122
+ // Never inferred from receipt fields without a pinned key or explicit verifier.
123
+ let proofTier = 'software';
124
+ try {
125
+ proofTier = receiptAssuranceTierFromProof(doc, opts) || 'software';
126
+ } catch { proofTier = 'software'; }
127
+ if ((TIER_RANK[proofTier] ?? 0) > (TIER_RANK[detail.tier] ?? 0)) detail.tier = proofTier;
128
+
129
+ // --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
130
+ const p = doc?.payload || {};
131
+ const verifyOpts = opts.rpId ? { rpId: opts.rpId } : {};
132
+
133
+ // quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
134
+ // under payload.quorum or payload.claim.quorum. It only counts if it is a full
135
+ // quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
136
+ // returns valid. A bare {signers,threshold} block has no members to verify and
137
+ // therefore CANNOT be credited quorum.
138
+ const q = p.quorum || p.claim?.quorum;
139
+ if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
140
+ const qr = verifyQuorum(q, verifyOpts);
141
+ detail.quorum = { valid: qr.valid, checks: qr.checks };
142
+ if (qr.valid) detail.tier = 'quorum';
143
+ }
144
+
145
+ // class_a: a verifiable WebAuthn device signoff. The signoff evidence is
146
+ // {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
147
+ // or alongside it (payload.approver_public_key).
148
+ if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
149
+ const so = p.signoff || p.claim?.signoff;
150
+ if (isSignoffEvidence(so)) {
151
+ const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
152
+ if (key) {
153
+ const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
154
+ detail.signoff = { valid: sr.valid, checks: sr.checks };
155
+ if (sr.valid && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
156
+ }
157
+ }
158
+ }
159
+
160
+ return opts.detail ? detail : detail.tier;
161
+ }
162
+
163
+ /** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
164
+ function isQuorumEvidence(q) {
165
+ return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
166
+ && typeof q.action_hash === 'string' && q.action_hash.length > 0;
167
+ }
168
+ /** A device signoff must carry the WebAuthn assertion material to be verifiable. */
169
+ function isSignoffEvidence(s) {
170
+ return !!s && typeof s === 'object' && s.context && s.webauthn;
79
171
  }
80
172
 
81
173
  /**
@@ -91,7 +183,7 @@ export function receiptAssuranceTier(doc, opts = {}) {
91
183
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
92
184
  * revoked) at its issuance time.
93
185
  */
94
- 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 } = {}) {
186
+ 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 } = {}) {
95
187
  // Production key custody: a registry (rotation + revocation) supersedes a flat
96
188
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
97
189
  // existing callers are unchanged.
@@ -187,20 +279,42 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
187
279
  if (!v.ok) {
188
280
  return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
189
281
  }
190
- // Assurance tier: proof-backed only. A receipt's issuer may describe a human
191
- // signoff, but high-tier enforcement requires pinned approver proof or an
192
- // explicit verifier hook. Self-asserted `allow_with_signoff` / `quorum`
193
- // fields are software-tier until proven.
282
+ // Assurance tier. CRYPTOGRAPHICALLY VERIFIED never inferred from
283
+ // self-asserted payload fields. The credited tier is the HIGHER of two
284
+ // independent proof paths:
285
+ // (a) pinned assurance proof (payload.assurance_proof verified against
286
+ // pinned approverKeys) or a caller-supplied verifyAssurance hook;
287
+ // (b) self-contained embedded per-signer evidence (EP-QUORUM-v1 /
288
+ // WebAuthn device signoff) re-verified via verifyQuorum /
289
+ // verifyWebAuthnSignoff (DoD audit fix).
290
+ // A receipt that only CLAIMS a higher tier earns 'software' and is refused.
194
291
  const assurance = evaluateReceiptAssurance(receipt, requiredTier, {
195
292
  approverKeys: approver_keys || approverKeys,
196
293
  verifyAssurance,
197
294
  });
198
- const have = assurance.have;
199
- if (!assurance.ok || (TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
200
- return decide(false, RECEIPT_REQUIRED_STATUS, assurance.reason || 'assurance_too_low', {
201
- have_tier: have,
202
- need_tier: requiredTier,
203
- assurance_tier_source: 'proof',
295
+ const tierResult = receiptAssuranceTier(receipt, {
296
+ rpId, detail: true, approverKeys: approver_keys || approverKeys, verifyAssurance,
297
+ });
298
+ // Take the strongest tier either path proves.
299
+ const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
300
+ ? assurance.have : tierResult.tier;
301
+ const needRank = TIER_RANK[requiredTier];
302
+ // Fail CLOSED on an unknown / mis-cased required tier: never silently treat
303
+ // it as 'software'. If a manifest asks for a tier this gate does not model,
304
+ // no receipt can satisfy it.
305
+ if (needRank === undefined) {
306
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'unknown_required_tier', { have_tier: have, need_tier: requiredTier, assurance_tier_source: 'cryptographic_verification' });
307
+ }
308
+ if ((TIER_RANK[have] ?? 0) < needRank) {
309
+ // The credited tier (from either proof path) is below what the action
310
+ // requires. The canonical machine-readable reason is 'assurance_too_low';
311
+ // main's proof-path detail (e.g. 'assurance_proof_required') is surfaced
312
+ // separately so callers keep the diagnostic without changing the contract.
313
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', {
314
+ have_tier: have, need_tier: requiredTier,
315
+ assurance_tier_source: 'cryptographic_verification',
316
+ assurance_detail: assurance.reason || null,
317
+ tier_evidence: { quorum: tierResult.quorum, signoff: tierResult.signoff },
204
318
  });
205
319
  }
206
320
  // The high-risk action packs define material fields that must be observed
@@ -208,7 +322,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
208
322
  // claim cannot authorize a different real mutation.
209
323
  const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
210
324
  if (!executionBinding.ok) {
211
- return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'proof' });
325
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'cryptographic_verification' });
212
326
  }
213
327
  // One-time consumption (replay defense). Require a stable, issuer-generated
214
328
  // receipt_id — never fall back to a content hash, whose canonicalization can
@@ -232,7 +346,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
232
346
  if (!fresh) {
233
347
  return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
234
348
  }
235
- return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'proof', execution_binding: executionBinding, consumption_mode: consumptionMode });
349
+ return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode });
236
350
  }
237
351
 
238
352
  /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -65,7 +65,8 @@
65
65
  "cf1": "node cf1.mjs"
66
66
  },
67
67
  "dependencies": {
68
- "@emilia-protocol/require-receipt": "^0.5.0"
68
+ "@emilia-protocol/require-receipt": "^0.5.0",
69
+ "@emilia-protocol/verify": "^3.3.0"
69
70
  },
70
71
  "keywords": [
71
72
  "emilia",