@emilia-protocol/gate 0.9.2 → 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.
Files changed (2) hide show
  1. package/index.js +78 -8
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -163,9 +163,26 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
163
163
  * verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
164
164
  * with the receipt rather than being pinned by the relying party.
165
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
+ *
166
178
  * @param {object} doc the EP-RECEIPT-v1 document
167
179
  * @param {object} [opts]
168
- * @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).
169
186
  * @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
170
187
  * @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
171
188
  * @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
@@ -185,30 +202,43 @@ export function receiptAssuranceTier(doc, opts = {}) {
185
202
  // --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
186
203
  const p = doc?.payload || {};
187
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));
188
211
 
189
212
  // quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
190
213
  // under payload.quorum or payload.claim.quorum. It only counts if it is a full
191
214
  // quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
192
215
  // returns valid. A bare {signers,threshold} block has no members to verify and
193
- // 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).
194
219
  const q = p.quorum || p.claim?.quorum;
195
220
  if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
196
221
  const qr = verifyQuorum(q, verifyOpts);
197
- detail.quorum = { valid: qr.valid, checks: qr.checks };
198
- 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';
199
227
  }
200
228
 
201
229
  // class_a: a verifiable WebAuthn device signoff. The signoff evidence is
202
230
  // {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
203
- // 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).
204
233
  if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
205
234
  const so = p.signoff || p.claim?.signoff;
206
235
  if (isSignoffEvidence(so)) {
207
236
  const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
208
237
  if (key) {
209
238
  const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
210
- detail.signoff = { valid: sr.valid, checks: sr.checks };
211
- 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';
212
242
  }
213
243
  }
214
244
  }
@@ -216,6 +246,34 @@ export function receiptAssuranceTier(doc, opts = {}) {
216
246
  return opts.detail ? detail : detail.tier;
217
247
  }
218
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
+
219
277
  /** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
220
278
  function isQuorumEvidence(q) {
221
279
  return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
@@ -238,8 +296,16 @@ function isSignoffEvidence(s) {
238
296
  * @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
239
297
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
240
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).
241
307
  */
242
- 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 } = {}) {
243
309
  // Production key custody: a registry (rotation + revocation) supersedes a flat
244
310
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
245
311
  // existing callers are unchanged.
@@ -367,6 +433,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
367
433
  });
368
434
  const tierResult = receiptAssuranceTier(receipt, {
369
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,
370
440
  });
371
441
  // Take the strongest tier either path proves.
372
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.2",
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",