@emilia-protocol/gate 0.9.1 → 0.9.3

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/index.js CHANGED
@@ -73,6 +73,13 @@ export {
73
73
  } from './action-control-manifest.js';
74
74
  export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
75
75
  export { RELIANCE_PACKET_VERSION, ADMISSIBILITY_VERDICTS, buildReliancePacket } from './reliance-packet.js';
76
+ export {
77
+ EXTERNAL_VERIFICATION_STATEMENT_VERSION,
78
+ EXTERNAL_VERIFICATION_DOMAIN,
79
+ externalVerificationDigest,
80
+ signExternalVerificationStatement,
81
+ verifyExternalVerificationStatement,
82
+ } from './reports/external-verification.js';
76
83
  export {
77
84
  EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
78
85
  createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
@@ -156,9 +163,26 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
156
163
  * verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
157
164
  * with the receipt rather than being pinned by the relying party.
158
165
  *
166
+ * TRUST-LAUNDERING GUARD: an approver key carried INSIDE the receipt proves
167
+ * only that whoever minted the receipt also holds that key — it is NOT proof
168
+ * the relying party trusts that human. Crediting an elevated tier off such a
169
+ * key would collapse VERIFIED into ACCEPTED (any party can mint a fresh
170
+ * keypair, self-sign a signoff, and embed both). So path (b) elevates the
171
+ * tier ONLY when either: (i) the caller explicitly opts in with
172
+ * `allowEmbeddedApproverKeys:true` (the documented self-contained mode,
173
+ * DEFAULT OFF); or (ii) every embedded approver key that would earn the
174
+ * credit is present in the relying party's PINNED approver key set
175
+ * (opts.approverKeys). With no pin and no opt-in, path (b) may still VERIFY
176
+ * the signoff/quorum, but it does NOT elevate above `software`. Fail-closed.
177
+ *
159
178
  * @param {object} doc the EP-RECEIPT-v1 document
160
179
  * @param {object} [opts]
161
- * @param {object} [opts.approverKeys] pinned approver keys for path (a)
180
+ * @param {object} [opts.approverKeys] pinned approver keys for path (a) and the
181
+ * path-(b) fallback: a receipt-embedded approver key elevates the tier only if
182
+ * it is one of these pinned keys (unless allowEmbeddedApproverKeys is set)
183
+ * @param {boolean} [opts.allowEmbeddedApproverKeys=false] explicit opt-in to the
184
+ * self-contained mode where an UNPINNED approver key carried inside the receipt
185
+ * may still elevate the path-(b) tier. DEFAULT OFF (fail-closed).
162
186
  * @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
163
187
  * @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
164
188
  * @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
@@ -178,30 +202,43 @@ export function receiptAssuranceTier(doc, opts = {}) {
178
202
  // --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
179
203
  const p = doc?.payload || {};
180
204
  const verifyOpts = opts.rpId ? { rpId: opts.rpId } : {};
205
+ // The relying party's PINNED approver public keys (base64url SPKI-DER strings).
206
+ // An embedded approver key elevates the tier only if it is in this set, unless
207
+ // the caller explicitly opts into the self-contained mode.
208
+ const allowEmbedded = opts.allowEmbeddedApproverKeys === true;
209
+ const pinnedKeys = pinnedApproverKeySet(opts.approverKeys);
210
+ const keyIsTrusted = (k) => allowEmbedded || (typeof k === 'string' && pinnedKeys.has(k));
181
211
 
182
212
  // quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
183
213
  // under payload.quorum or payload.claim.quorum. It only counts if it is a full
184
214
  // quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
185
215
  // returns valid. A bare {signers,threshold} block has no members to verify and
186
- // therefore CANNOT be credited quorum.
216
+ // therefore CANNOT be credited quorum. The cryptographic verification runs
217
+ // regardless (so `detail.quorum` reports validity), but the tier elevates only
218
+ // when every member's embedded approver key is pinned (or the caller opted in).
187
219
  const q = p.quorum || p.claim?.quorum;
188
220
  if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
189
221
  const qr = verifyQuorum(q, verifyOpts);
190
- detail.quorum = { valid: qr.valid, checks: qr.checks };
191
- if (qr.valid) detail.tier = 'quorum';
222
+ const membersTrusted = allowEmbedded
223
+ || (Array.isArray(q.members) && q.members.length > 0
224
+ && q.members.every((m) => keyIsTrusted(m?.approver_public_key)));
225
+ detail.quorum = { valid: qr.valid, checks: qr.checks, embedded_keys_trusted: membersTrusted };
226
+ if (qr.valid && membersTrusted) detail.tier = 'quorum';
192
227
  }
193
228
 
194
229
  // class_a: a verifiable WebAuthn device signoff. The signoff evidence is
195
230
  // {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
196
- // or alongside it (payload.approver_public_key).
231
+ // or alongside it (payload.approver_public_key). That key elevates the tier only
232
+ // when it is pinned by the relying party (or the caller opted into embedded keys).
197
233
  if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
198
234
  const so = p.signoff || p.claim?.signoff;
199
235
  if (isSignoffEvidence(so)) {
200
236
  const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
201
237
  if (key) {
202
238
  const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
203
- detail.signoff = { valid: sr.valid, checks: sr.checks };
204
- if (sr.valid && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
239
+ const trusted = keyIsTrusted(key);
240
+ detail.signoff = { valid: sr.valid, checks: sr.checks, embedded_key_trusted: trusted };
241
+ if (sr.valid && trusted && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
205
242
  }
206
243
  }
207
244
  }
@@ -209,6 +246,34 @@ export function receiptAssuranceTier(doc, opts = {}) {
209
246
  return opts.detail ? detail : detail.tier;
210
247
  }
211
248
 
249
+ /**
250
+ * The set of PINNED approver public keys (base64url SPKI-DER strings) a relying
251
+ * party trusts, from the same `approverKeys` map path (a) uses. Accepts either a
252
+ * map of { keyId: { public_key } } (the EP-ASSURANCE-PROOF-v1 shape) or a plain
253
+ * array/set of key strings. Used to decide whether a receipt-embedded approver
254
+ * key may elevate the path-(b) tier. Never throws.
255
+ */
256
+ function pinnedApproverKeySet(approverKeys) {
257
+ const out = new Set();
258
+ if (!approverKeys) return out;
259
+ if (approverKeys instanceof Set) {
260
+ for (const k of approverKeys) if (typeof k === 'string' && k) out.add(k);
261
+ return out;
262
+ }
263
+ if (Array.isArray(approverKeys)) {
264
+ for (const k of approverKeys) if (typeof k === 'string' && k) out.add(k);
265
+ return out;
266
+ }
267
+ if (typeof approverKeys === 'object') {
268
+ for (const entry of Object.values(approverKeys)) {
269
+ if (typeof entry === 'string' && entry) { out.add(entry); continue; }
270
+ const pk = entry && typeof entry === 'object' ? entry.public_key : null;
271
+ if (typeof pk === 'string' && pk) out.add(pk);
272
+ }
273
+ }
274
+ return out;
275
+ }
276
+
212
277
  /** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
213
278
  function isQuorumEvidence(q) {
214
279
  return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
@@ -231,8 +296,16 @@ function isSignoffEvidence(s) {
231
296
  * @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
232
297
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
233
298
  * revoked) at its issuance time.
299
+ * @param {object} [opts.approverKeys] PINNED approver keys ({ keyId: { public_key, key_class } }).
300
+ * Used both for the pinned assurance-proof path and to authorize receipt-embedded
301
+ * approver keys under the self-contained embedded-evidence path.
302
+ * @param {boolean} [opts.allowEmbeddedApproverKeys=false] opt into the self-contained
303
+ * embedded-evidence mode: when true, a class_a/quorum tier may be credited from an
304
+ * approver key carried INSIDE the receipt even if that key is not pinned. DEFAULT OFF
305
+ * — with no pinned approverKeys and no opt-in, embedded evidence verifies but does not
306
+ * elevate above 'software' (prevents VERIFIED collapsing into ACCEPTED / trust-laundering).
234
307
  */
235
- 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 } = {}) {
308
+ 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 } = {}) {
236
309
  // Production key custody: a registry (rotation + revocation) supersedes a flat
237
310
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
238
311
  // existing callers are unchanged.
@@ -360,6 +433,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
360
433
  });
361
434
  const tierResult = receiptAssuranceTier(receipt, {
362
435
  rpId, detail: true, approverKeys: approver_keys || approverKeys, verifyAssurance,
436
+ // Trust-laundering guard: a receipt-embedded approver key does NOT elevate
437
+ // the tier unless it is in the pinned approverKeys set, or the operator
438
+ // explicitly opted into the self-contained embedded-evidence mode. DEFAULT OFF.
439
+ allowEmbeddedApproverKeys,
363
440
  });
364
441
  // Take the strongest tier either path proves.
365
442
  const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.9.1",
4
- "description": "EMILIA Gate \u2014 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.",
3
+ "version": "0.9.3",
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",
7
7
  "main": "index.js",
@@ -34,7 +34,8 @@
34
34
  "./breakglass": "./breakglass.js",
35
35
  "./store-postgres": "./store-postgres.js",
36
36
  "./reports/auditor-workpaper": "./reports/auditor-workpaper.js",
37
- "./reports/reperform": "./reports/reperform.js"
37
+ "./reports/reperform": "./reports/reperform.js",
38
+ "./reports/external-verification": "./reports/external-verification.js"
38
39
  },
39
40
  "files": [
40
41
  "index.js",
@@ -91,7 +92,8 @@
91
92
  "deploy/terraform/versions.tf",
92
93
  "deploy/terraform/README.md",
93
94
  "reports/auditor-workpaper.js",
94
- "reports/reperform.js"
95
+ "reports/reperform.js",
96
+ "reports/external-verification.js"
95
97
  ],
96
98
  "scripts": {
97
99
  "test": "node --test",
@@ -0,0 +1,223 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EP-EXTERNAL-VERIFICATION-STATEMENT-v1.
4
+ *
5
+ * A signed statement a NON-EMILIA verifier can issue after it re-performs an
6
+ * evidence log, replays an admissibility profile, or runs a conformance harness.
7
+ * This is the missing adoption rail between "our verifier works" and "an
8
+ * outside party says exactly what they checked."
9
+ *
10
+ * Scope is intentionally narrow:
11
+ * - the statement signs a procedure, inputs, result, and limitations;
12
+ * - it does NOT authorize an action;
13
+ * - it does NOT certify business correctness;
14
+ * - acceptance is by a relying party pinning the external verifier key.
15
+ */
16
+ import crypto from 'node:crypto';
17
+ // In-package canonicalize (byte-identical to lib/canonical-json.js): reports must
18
+ // never import outside the package root or the published tarball cannot resolve it.
19
+ import { canonicalize } from '../execution-binding.js';
20
+
21
+ export const EXTERNAL_VERIFICATION_STATEMENT_VERSION = 'EP-EXTERNAL-VERIFICATION-STATEMENT-v1';
22
+ export const EXTERNAL_VERIFICATION_DOMAIN = 'EP-EXTERNAL-VERIFICATION-STATEMENT-v1\0';
23
+
24
+ const SHA256_RE = /^sha256:[0-9a-f]{64}$/i;
25
+
26
+ function sha256hex(bytes) {
27
+ return crypto.createHash('sha256').update(bytes).digest('hex');
28
+ }
29
+
30
+ function publicKeyToB64u(key) {
31
+ return crypto.createPublicKey(key).export({ type: 'spki', format: 'der' }).toString('base64url');
32
+ }
33
+
34
+ function keyIdFor(publicKeyB64u) {
35
+ return `ep:external-verifier-key:sha256:${sha256hex(Buffer.from(publicKeyB64u, 'base64url')).slice(0, 16)}`;
36
+ }
37
+
38
+ function signingBytes(unsignedStatement) {
39
+ return Buffer.from(EXTERNAL_VERIFICATION_DOMAIN + canonicalize(unsignedStatement), 'utf8');
40
+ }
41
+
42
+ function unsigned(statement) {
43
+ if (!statement || typeof statement !== 'object' || Array.isArray(statement)) {
44
+ throw new Error('statement must be an object');
45
+ }
46
+ const { signature: _signature, ...body } = statement;
47
+ return body;
48
+ }
49
+
50
+ /** Digest of the signed statement body, excluding the signature envelope. */
51
+ export function externalVerificationDigest(statement) {
52
+ return `sha256:${sha256hex(signingBytes(unsigned(statement)))}`;
53
+ }
54
+
55
+ function normalizeChecks(checks) {
56
+ if (!Array.isArray(checks)) return [];
57
+ return checks.map((c) => ({
58
+ id: String(c?.id ?? ''),
59
+ ok: c?.ok === true,
60
+ ...(c?.detail !== undefined ? { detail: c.detail } : {}),
61
+ })).filter((c) => c.id);
62
+ }
63
+
64
+ /**
65
+ * Build and sign an external-verifier statement.
66
+ *
67
+ * @param {object} args
68
+ * @param {object} args.verifier {id, name?, organization?}
69
+ * @param {object} args.subject what was checked, e.g. {kind:'evidence_log', head:'sha256:...'}
70
+ * @param {object} args.procedure {id, version?, tool?, command?}
71
+ * @param {object} args.result {status, checks?, artifact_digest?}
72
+ * @param {object} [args.inputs] stable digests/ids the procedure consumed
73
+ * @param {string[]} [args.limitations] honest non-claims
74
+ * @param {string|number} [args.generated_at] ISO or epoch millis
75
+ * @param {crypto.KeyObject} privateKey Ed25519 private key
76
+ */
77
+ export function signExternalVerificationStatement(args, privateKey) {
78
+ if (!privateKey) throw new Error('privateKey is required');
79
+ const generatedAt = args?.generated_at !== undefined
80
+ ? new Date(args.generated_at).toISOString()
81
+ : new Date().toISOString();
82
+ const publicKey = publicKeyToB64u(privateKey);
83
+ const body = {
84
+ '@version': EXTERNAL_VERIFICATION_STATEMENT_VERSION,
85
+ generated_at: generatedAt,
86
+ verifier: {
87
+ id: args?.verifier?.id ?? keyIdFor(publicKey),
88
+ ...(args?.verifier?.name ? { name: args.verifier.name } : {}),
89
+ ...(args?.verifier?.organization ? { organization: args.verifier.organization } : {}),
90
+ },
91
+ subject: args?.subject ?? {},
92
+ procedure: args?.procedure ?? {},
93
+ inputs: args?.inputs ?? {},
94
+ result: {
95
+ status: String(args?.result?.status ?? 'unknown'),
96
+ checks: normalizeChecks(args?.result?.checks),
97
+ ...(args?.result?.artifact_digest ? { artifact_digest: args.result.artifact_digest } : {}),
98
+ },
99
+ limitations: Array.isArray(args?.limitations) && args.limitations.length
100
+ ? args.limitations.map(String)
101
+ : [
102
+ 'This statement records the external verifier procedure and result; it does not authorize the action.',
103
+ 'It does not certify business correctness, legal compliance, or human wisdom.',
104
+ 'Acceptance depends on the relying party pinning the verifier key out of band.',
105
+ 'The statement carries no expiry and no consumer binding; it is replayable verbatim, and generated_at is asserted by the signer, not verified.',
106
+ ],
107
+ };
108
+
109
+ const digest = externalVerificationDigest(body);
110
+ const sig = crypto.sign(null, signingBytes(body), privateKey).toString('base64url');
111
+ return Object.freeze({
112
+ ...body,
113
+ signature: {
114
+ algorithm: 'Ed25519',
115
+ key_id: keyIdFor(publicKey),
116
+ public_key: publicKey,
117
+ statement_digest: digest,
118
+ signature_b64u: sig,
119
+ },
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Verify a signed external-verifier statement against pinned verifier keys.
125
+ *
126
+ * @param {object} statement
127
+ * @param {{pinnedVerifierKeys:Array<{verifier_id?:string,key_id?:string,public_key:string}>}} opts
128
+ * @returns {{verified:boolean, accepted:boolean, checks:object, reason?:string, statement_digest?:string}}
129
+ */
130
+ export function verifyExternalVerificationStatement(statement, opts = {}) {
131
+ const fail = (reason, extra = {}) => ({
132
+ verified: false,
133
+ accepted: false,
134
+ checks: {
135
+ version: statement?.['@version'] === EXTERNAL_VERIFICATION_STATEMENT_VERSION,
136
+ signature: false,
137
+ pinned_verifier_key: false,
138
+ statement_digest: false,
139
+ },
140
+ reason,
141
+ ...extra,
142
+ });
143
+
144
+ if (statement?.['@version'] !== EXTERNAL_VERIFICATION_STATEMENT_VERSION) {
145
+ return fail('unsupported_version');
146
+ }
147
+ const sig = statement.signature;
148
+ if (!sig || sig.algorithm !== 'Ed25519' || typeof sig.public_key !== 'string' || typeof sig.signature_b64u !== 'string') {
149
+ return fail('signature_missing_or_malformed');
150
+ }
151
+ if (typeof sig.statement_digest !== 'string' || !SHA256_RE.test(sig.statement_digest)) {
152
+ return fail('statement_digest_missing_or_malformed');
153
+ }
154
+
155
+ let digest;
156
+ try {
157
+ digest = externalVerificationDigest(statement);
158
+ } catch {
159
+ return fail('statement_uncanonicalizable');
160
+ }
161
+ if (digest !== sig.statement_digest) {
162
+ return fail('statement_digest_mismatch', { statement_digest: digest });
163
+ }
164
+
165
+ const pinned = Array.isArray(opts.pinnedVerifierKeys) ? opts.pinnedVerifierKeys : [];
166
+ const verifierId = statement.verifier?.id ?? null;
167
+ // key_id is always DERIVED from the carried public key. The envelope's key_id is
168
+ // outside the signed bytes, so it is attacker-malleable; if present it must match
169
+ // the derived value or the statement is refused.
170
+ const keyId = keyIdFor(sig.public_key);
171
+ if (sig.key_id !== undefined && sig.key_id !== keyId) {
172
+ return fail('key_id_mismatch', { statement_digest: digest });
173
+ }
174
+ // A pin grants an identity, not just a key: every usable pin entry must name the
175
+ // verifier_id it vouches for. A pin that matches the key but omits verifier_id
176
+ // (or names a different one) never binds the statement's claimed identity.
177
+ const keyMatched = pinned.filter((k) => k?.public_key === sig.public_key
178
+ && (k.key_id === undefined || k.key_id === keyId));
179
+ const pin = keyMatched.find((k) => typeof k.verifier_id === 'string' && k.verifier_id === verifierId);
180
+ if (!pin) {
181
+ return {
182
+ verified: false,
183
+ accepted: false,
184
+ checks: { version: true, signature: false, pinned_verifier_key: false, statement_digest: true },
185
+ reason: keyMatched.length ? 'pin_missing_or_mismatched_verifier_id' : 'verifier_key_not_pinned',
186
+ statement_digest: digest,
187
+ };
188
+ }
189
+
190
+ let ok = false;
191
+ try {
192
+ const publicKey = crypto.createPublicKey({ key: Buffer.from(sig.public_key, 'base64url'), type: 'spki', format: 'der' });
193
+ ok = crypto.verify(null, signingBytes(unsigned(statement)), publicKey, Buffer.from(sig.signature_b64u, 'base64url'));
194
+ } catch {
195
+ ok = false;
196
+ }
197
+ if (!ok) {
198
+ return {
199
+ verified: false,
200
+ accepted: false,
201
+ checks: { version: true, signature: false, pinned_verifier_key: true, statement_digest: true },
202
+ reason: 'signature_invalid',
203
+ statement_digest: digest,
204
+ };
205
+ }
206
+
207
+ return {
208
+ verified: true,
209
+ accepted: true,
210
+ checks: { version: true, signature: true, pinned_verifier_key: true, statement_digest: true },
211
+ verifier_id: verifierId,
212
+ key_id: keyId,
213
+ statement_digest: digest,
214
+ };
215
+ }
216
+
217
+ export default {
218
+ EXTERNAL_VERIFICATION_STATEMENT_VERSION,
219
+ EXTERNAL_VERIFICATION_DOMAIN,
220
+ externalVerificationDigest,
221
+ signExternalVerificationStatement,
222
+ verifyExternalVerificationStatement,
223
+ };