@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 +7 -0
- package/demo.mjs +10 -1
- package/eg1-conformance.js +111 -13
- package/index.js +134 -20
- package/package.json +3 -2
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
|
|
package/eg1-conformance.js
CHANGED
|
@@ -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
|
|
144
|
-
*
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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: '
|
|
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: '
|
|
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.
|
|
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",
|